Skip to content

Commit 2f5a1c0

Browse files
feat(nip42): add session tracking and optional authRequired (#716)
* feat(nip42): add session tracking and optional authRequired * fix: silence CodeQL false positive on public NIP-11 routes * fix(nip42): align NIP-11 ads and reject AUTH event replay * refactor(nip42): rename sessionTtl to sessionExpirySeconds --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent df1ed5d commit 2f5a1c0

18 files changed

Lines changed: 409 additions & 28 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): add session tracking with optional TTL and publish-time authRequired (NIP-11 restricted_writes)

‎CONFIGURATION.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta
192192
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
193193
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
194194
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
195+
| nip42.authRequired | When true, clients must NIP-42 AUTH as the event author before publishing events. Advertised in NIP-11 as `limitation.restricted_writes` (not `auth_required`, which means AUTH before any connection action). Defaults to false. |
196+
| nip42.sessionExpirySeconds | Seconds after which an authenticated pubkey must AUTH again on the same WebSocket. `0` (default) keeps the session for the connection lifetime. |
195197
| 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. |
196198
| 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). |
197199
| nip43.enabled | Enable NIP-43 invite-based membership. When true, only admitted members may publish. Defaults to false. |

‎resources/default-settings.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ nip05:
6161
# Block authors with NIP-05 at these domains
6262
domainBlacklist: []
6363
nip42:
64+
# When true, clients must AUTH (NIP-42) as the event author before publishing.
65+
# Advertised in NIP-11 as limitation.restricted_writes (not auth_required).
66+
authRequired: false
67+
# Seconds after which an authenticated session on a socket expires and must
68+
# AUTH again. 0 (default) keeps the session for the connection lifetime.
69+
sessionExpirySeconds: 0
6470
# Only deliver these kinds to clients authenticated (NIP-42) as the event's
6571
# author or a p-tagged recipient. Applies to REQ, live events and COUNT.
6672
restrictedReads:

‎src/@types/adapters.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export type IWebSocketAdapter = EventEmitter & {
1717
getSubscriptions(): Map<string, SubscriptionFilter[]>
1818
getChallenge(): string
1919
getAuthenticatedPubkeys(): ReadonlySet<string>
20-
addAuthenticatedPubkey(pubkey: string): void
20+
/** Returns false if this AUTH event id was already accepted on this socket. */
21+
addAuthenticatedPubkey(pubkey: string, authEventId: string): boolean
2122
}
2223

2324
export interface ICacheAdapter {

‎src/@types/settings.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,17 @@ export interface Nip42RestrictedReads {
369369
}
370370

371371
export interface Nip42Settings {
372+
/**
373+
* When true, clients must NIP-42 AUTH as the event author before publishing.
374+
* Advertised via NIP-11 `limitation.restricted_writes` (not `auth_required`,
375+
* which means AUTH before any connection action).
376+
*/
377+
authRequired?: boolean
378+
/**
379+
* Seconds after which an authenticated pubkey must AUTH again on this socket.
380+
* Omit, 0, or negative = session lasts for the connection lifetime (NIP-42 default).
381+
*/
382+
sessionExpirySeconds?: number
372383
restrictedReads?: Nip42RestrictedReads
373384
}
374385

‎src/adapters/web-socket-adapter.ts‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { randomBytes } from 'crypto'
21
import cluster from 'cluster'
32
import { EventEmitter } from 'stream'
43
import { IncomingMessage as IncomingHttpMessage } from 'http'
@@ -19,6 +18,7 @@ import { recordWebsocketConnectionClosed, recordWebsocketConnectionOpened } from
1918
import { Event } from '../@types/event'
2019
import { getRemoteAddress } from '../utils/http'
2120
import { createReadAuthorizationGuard } from '../utils/nip42'
21+
import { Nip42SessionManager } from '../utils/nip42-session'
2222
import { IRateLimiter } from '../@types/utils'
2323
import { isEventMatchingFilter } from '../utils/event'
2424
import { messageSchema } from '../schemas/message-schema'
@@ -35,8 +35,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
3535
private clientAddress: SocketAddress
3636
private alive: boolean
3737
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
38-
private readonly challenge: string
39-
private readonly authenticatedPubkeys: Set<string>
38+
private readonly session: Nip42SessionManager
4039

4140
public constructor(
4241
private readonly client: WebSocket,
@@ -86,10 +85,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
8685
logger('client %s connected from %s', this.clientId, this.clientAddress.address)
8786
recordWebsocketConnectionOpened()
8887

89-
// NIP-42
90-
this.challenge = randomBytes(32).toString('base64url')
91-
this.authenticatedPubkeys = new Set()
92-
this.sendMessage(createAuthChallengeMessage(this.challenge))
88+
// NIP-42: challenge-response session for this socket
89+
this.session = new Nip42SessionManager(() => this.settings().nip42?.sessionExpirySeconds)
90+
this.sendMessage(createAuthChallengeMessage(this.session.getChallenge()))
9391
}
9492

9593
public getClientId(): string {
@@ -122,7 +120,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
122120

123121
public onSendEvent(event: Event): void {
124122
// NIP-42: don't broadcast restricted-kind events to unauthorized clients.
125-
const isReadAuthorized = createReadAuthorizationGuard(this.settings(), () => this.authenticatedPubkeys)
123+
const isReadAuthorized = createReadAuthorizationGuard(this.settings(), () => this.session.getAuthenticatedPubkeys())
126124
if (!isReadAuthorized(event)) {
127125
return
128126
}
@@ -160,15 +158,17 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
160158

161159
// NIP-42
162160
public getChallenge(): string {
163-
return this.challenge
161+
return this.session.getChallenge()
164162
}
165163

166164
public getAuthenticatedPubkeys(): ReadonlySet<string> {
167-
return new Set(this.authenticatedPubkeys)
165+
return this.session.getAuthenticatedPubkeys()
168166
}
169167

170-
public addAuthenticatedPubkey(pubkey: string): void {
171-
this.authenticatedPubkeys.add(pubkey)
168+
public addAuthenticatedPubkey(pubkey: string, authEventId: string): boolean {
169+
// Keep the existing challenge. NIP-42 allows multiple AUTH events on one
170+
// socket to share it; rotating here would break pipelined multi-pubkey auth.
171+
return this.session.authenticate(pubkey, authEventId)
172172
}
173173

174174
private async onClientMessage(raw: Buffer) {
@@ -271,7 +271,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
271271
recordWebsocketConnectionClosed()
272272
this.alive = false
273273
this.subscriptions.clear()
274-
this.authenticatedPubkeys.clear()
274+
this.session.clear()
275275

276276
const handlers = abortableMessageHandlers.get(this.client)
277277
if (Array.isArray(handlers) && handlers.length) {

‎src/handlers/auth-message-handler.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ export class AuthMessageHandler implements IMessageHandler {
6262
}
6363

6464
logger('client %s authenticated as %s', this.webSocket.getClientId(), event.pubkey)
65-
this.webSocket.addAuthenticatedPubkey(event.pubkey)
65+
if (!this.webSocket.addAuthenticatedPubkey(event.pubkey, event.id)) {
66+
this.sendResult(event.id, false, 'invalid: auth event already used')
67+
return
68+
}
6669
this.sendResult(event.id, true, '')
6770
}
6871

‎src/handlers/event-message-handler.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
isSealEvent,
2929
isWelcomeRumorEvent,
3030
} from '../utils/event'
31+
import { isAuthRequired } from '../utils/nip42'
3132
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
3233
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
3334
import { admissionCacheKey, CacheAdmissionState } from '../constants/caching'
@@ -91,6 +92,13 @@ export class EventMessageHandler implements IMessageHandler {
9192
return
9293
}
9394

95+
reason = this.isAuthenticationRequired(event)
96+
if (reason) {
97+
logger('event %s rejected: %s', event.id, reason)
98+
this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(event.id, false, reason))
99+
return
100+
}
101+
94102
reason = await this.isProtectedEventBlocked(event)
95103
if (reason) {
96104
logger('event %s rejected: %s', event.id, reason)
@@ -234,6 +242,20 @@ export class EventMessageHandler implements IMessageHandler {
234242
}
235243
}
236244

245+
protected isAuthenticationRequired(event: Event): string | undefined {
246+
if (!isAuthRequired(this.settings())) {
247+
return
248+
}
249+
250+
if (this.getRelayPublicKey() === event.pubkey) {
251+
return
252+
}
253+
254+
if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {
255+
return 'auth-required: authentication is required to publish events'
256+
}
257+
}
258+
237259
protected async isProtectedEventBlocked(event: Event): Promise<string | undefined> {
238260
if (isProtectedEvent(event)) {
239261
if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {

‎src/handlers/request-handlers/root-request-handler.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
6161
const hasWriteRestriction =
6262
hasAdmissionRestriction ||
6363
settings.nip43?.enabled === true ||
64+
// Publish-only NIP-42 auth is a write condition, not connection-wide auth_required.
65+
settings.nip42?.authRequired === true ||
6466
(eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 ||
6567
(eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 ||
6668
(eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 ||
@@ -101,6 +103,8 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
101103
? content[0].maxLength // best guess since we have per-kind limits
102104
: content?.maxLength,
103105
min_pow_difficulty: eventLimits?.eventId?.minLeadingZeroBits,
106+
// NIP-11: auth_required means AUTH before any action. We only gate publishes
107+
// via nip42.authRequired (advertised as restricted_writes instead).
104108
auth_required: false,
105109
payment_required: settings.payments?.enabled,
106110
created_at_lower_limit: createdAtLimits?.maxNegativeDelta,

‎src/routes/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@ import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handler
1313

1414
const router: Router = express.Router()
1515

16+
// Public NIP-11 / homepage — advertises relay metadata only; not an authentication endpoint.
17+
// codeql[js/missing-rate-limiting]
1618
router.use((req, res, next) => {
1719
if (req.method === 'GET' && req.path === '/' && hasExplicitNostrJsonAcceptHeader(req)) {
1820
return rootRequestHandler(req, res, next)
1921
}
2022
next()
2123
})
2224

25+
// codeql[js/missing-rate-limiting]
2326
router.get('/', rootRequestHandler)
2427
router.get('/healthz', getHealthRequestHandler)
2528
router.get('/terms', getTermsRequestHandler)

0 commit comments

Comments
 (0)