Skip to content
5 changes: 5 additions & 0 deletions .changeset/nip42-session-manager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(nip42): add session tracking with optional TTL and publish-time authRequired (NIP-11 restricted_writes)
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,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.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. |
| nip42.sessionExpirySeconds | Seconds after which an authenticated pubkey must AUTH again on the same WebSocket. `0` (default) keeps the session for the connection lifetime. |
| 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). |
| nip43.enabled | Enable NIP-43 invite-based membership. When true, only admitted members may publish. Defaults to false. |
Expand Down
6 changes: 6 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ nip05:
# Block authors with NIP-05 at these domains
domainBlacklist: []
nip42:
# When true, clients must AUTH (NIP-42) as the event author before publishing.
# Advertised in NIP-11 as limitation.restricted_writes (not auth_required).
authRequired: false
# Seconds after which an authenticated session on a socket expires and must
# AUTH again. 0 (default) keeps the session for the connection lifetime.
sessionExpirySeconds: 0
# 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:
Expand Down
3 changes: 2 additions & 1 deletion src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export type IWebSocketAdapter = EventEmitter & {
getSubscriptions(): Map<string, SubscriptionFilter[]>
getChallenge(): string
getAuthenticatedPubkeys(): ReadonlySet<string>
addAuthenticatedPubkey(pubkey: string): void
/** Returns false if this AUTH event id was already accepted on this socket. */
addAuthenticatedPubkey(pubkey: string, authEventId: string): boolean
}

export interface ICacheAdapter {
Expand Down
11 changes: 11 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,17 @@ export interface Nip42RestrictedReads {
}

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

Expand Down
26 changes: 13 additions & 13 deletions src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { randomBytes } from 'crypto'
import cluster from 'cluster'
import { EventEmitter } from 'stream'
import { IncomingMessage as IncomingHttpMessage } from 'http'
Expand All @@ -19,6 +18,7 @@ import { recordWebsocketConnectionClosed, recordWebsocketConnectionOpened } from
import { Event } from '../@types/event'
import { getRemoteAddress } from '../utils/http'
import { createReadAuthorizationGuard } from '../utils/nip42'
import { Nip42SessionManager } from '../utils/nip42-session'
import { IRateLimiter } from '../@types/utils'
import { isEventMatchingFilter } from '../utils/event'
import { messageSchema } from '../schemas/message-schema'
Expand All @@ -35,8 +35,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private clientAddress: SocketAddress
private alive: boolean
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
private readonly challenge: string
private readonly authenticatedPubkeys: Set<string>
private readonly session: Nip42SessionManager

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

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

public getClientId(): string {
Expand Down Expand Up @@ -122,7 +120,7 @@ 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)
const isReadAuthorized = createReadAuthorizationGuard(this.settings(), () => this.session.getAuthenticatedPubkeys())
if (!isReadAuthorized(event)) {
return
}
Expand Down Expand Up @@ -160,15 +158,17 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter

// NIP-42
public getChallenge(): string {
return this.challenge
return this.session.getChallenge()
}

public getAuthenticatedPubkeys(): ReadonlySet<string> {
return new Set(this.authenticatedPubkeys)
return this.session.getAuthenticatedPubkeys()
}

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

private async onClientMessage(raw: Buffer) {
Expand Down Expand Up @@ -271,7 +271,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
recordWebsocketConnectionClosed()
this.alive = false
this.subscriptions.clear()
this.authenticatedPubkeys.clear()
this.session.clear()

const handlers = abortableMessageHandlers.get(this.client)
if (Array.isArray(handlers) && handlers.length) {
Expand Down
5 changes: 4 additions & 1 deletion src/handlers/auth-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export class AuthMessageHandler implements IMessageHandler {
}

logger('client %s authenticated as %s', this.webSocket.getClientId(), event.pubkey)
this.webSocket.addAuthenticatedPubkey(event.pubkey)
if (!this.webSocket.addAuthenticatedPubkey(event.pubkey, event.id)) {
this.sendResult(event.id, false, 'invalid: auth event already used')
return
}
this.sendResult(event.id, true, '')
}

Expand Down
22 changes: 22 additions & 0 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
isSealEvent,
isWelcomeRumorEvent,
} from '../utils/event'
import { isAuthRequired } from '../utils/nip42'
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
import { IEventStrategy, IMessageHandler } from '../@types/message-handlers'
import { admissionCacheKey, CacheAdmissionState } from '../constants/caching'
Expand Down Expand Up @@ -91,6 +92,13 @@ export class EventMessageHandler implements IMessageHandler {
return
}

reason = this.isAuthenticationRequired(event)
if (reason) {
logger('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(event.id, false, reason))
return
}

reason = await this.isProtectedEventBlocked(event)
if (reason) {
logger('event %s rejected: %s', event.id, reason)
Expand Down Expand Up @@ -234,6 +242,20 @@ export class EventMessageHandler implements IMessageHandler {
}
}

protected isAuthenticationRequired(event: Event): string | undefined {
if (!isAuthRequired(this.settings())) {
return
}

if (this.getRelayPublicKey() === event.pubkey) {
return
}

if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {
return 'auth-required: authentication is required to publish events'
}
}

protected async isProtectedEventBlocked(event: Event): Promise<string | undefined> {
if (isProtectedEvent(event)) {
if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) {
Expand Down
4 changes: 4 additions & 0 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
const hasWriteRestriction =
hasAdmissionRestriction ||
settings.nip43?.enabled === true ||
// Publish-only NIP-42 auth is a write condition, not connection-wide auth_required.
settings.nip42?.authRequired === true ||
(eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 ||
(eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 ||
(eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 ||
Expand Down Expand Up @@ -101,6 +103,8 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
? content[0].maxLength // best guess since we have per-kind limits
: content?.maxLength,
min_pow_difficulty: eventLimits?.eventId?.minLeadingZeroBits,
// NIP-11: auth_required means AUTH before any action. We only gate publishes
// via nip42.authRequired (advertised as restricted_writes instead).
auth_required: false,
payment_required: settings.payments?.enabled,
created_at_lower_limit: createdAtLimits?.maxNegativeDelta,
Expand Down
3 changes: 3 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ import { hasExplicitNostrJsonAcceptHeader, rootRequestHandler } from '../handler

const router: Router = express.Router()

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

// codeql[js/missing-rate-limiting]
router.get('/', rootRequestHandler)
router.get('/healthz', getHealthRequestHandler)
router.get('/terms', getTermsRequestHandler)
Expand Down
92 changes: 92 additions & 0 deletions src/utils/nip42-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { randomBytes } from 'crypto'

import { Pubkey } from '../@types/base'

export interface Nip42Session {
pubkey: Pubkey
authenticatedAt: number
}

/**
* Per-connection NIP-42 session state.
*
* Auth is connection-scoped (per the NIP): one challenge per socket, successful
* AUTH messages add pubkeys, and the session ends when the socket closes.
* Optional TTL can force re-AUTH after a configured lifetime (off by default).
*
* Accepted AUTH event IDs are remembered for the connection so the same signed
* AUTH event cannot be replayed to refresh sessionExpirySeconds.
*/
export class Nip42SessionManager {
private challenge: string
private readonly sessions = new Map<string, Nip42Session>()
private readonly acceptedAuthEventIds = new Set<string>()

public constructor(private readonly getSessionTtlSeconds: () => number | undefined = () => undefined) {
this.challenge = Nip42SessionManager.createChallenge()
}

public static createChallenge(): string {
return randomBytes(32).toString('base64url')
}

public getChallenge(): string {
return this.challenge
}

/** Replace the active challenge. Only call when intentionally issuing a new AUTH. */
public rotateChallenge(): string {
this.challenge = Nip42SessionManager.createChallenge()
return this.challenge
}

/**
* Record a successful AUTH. Returns false if this AUTH event id was already
* accepted on this socket (replay).
*/
public authenticate(pubkey: Pubkey, authEventId: string, now = Math.floor(Date.now() / 1000)): boolean {
if (this.acceptedAuthEventIds.has(authEventId)) {
return false
}

this.acceptedAuthEventIds.add(authEventId)
this.sessions.set(pubkey, { pubkey, authenticatedAt: now })
return true
}

public clear(pubkey?: Pubkey): void {
if (typeof pubkey === 'undefined') {
this.sessions.clear()
this.acceptedAuthEventIds.clear()
return
}
this.sessions.delete(pubkey)
}

public getSession(pubkey: Pubkey, now = Math.floor(Date.now() / 1000)): Nip42Session | undefined {
this.pruneExpired(now)
return this.sessions.get(pubkey)
}

public getAuthenticatedPubkeys(now = Math.floor(Date.now() / 1000)): ReadonlySet<Pubkey> {
this.pruneExpired(now)
return new Set(this.sessions.keys())
}

public isAuthenticated(pubkey: Pubkey, now = Math.floor(Date.now() / 1000)): boolean {
return typeof this.getSession(pubkey, now) !== 'undefined'
}

private pruneExpired(now: number): void {
const ttl = this.getSessionTtlSeconds()
if (!ttl || ttl <= 0) {
return
}

for (const [pubkey, session] of this.sessions) {
if (now - session.authenticatedAt >= ttl) {
this.sessions.delete(pubkey)
}
}
}
}
2 changes: 2 additions & 0 deletions src/utils/nip42.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const DEFAULT_RESTRICTED_READ_KINDS: (EventKinds | EventKindsRange)[] = [
EventKinds.GIFT_WRAP,
]

export const isAuthRequired = (settings: Settings | undefined): boolean => settings?.nip42?.authRequired === true

export const getRestrictedReadKinds = (settings: Settings | undefined): (EventKinds | EventKindsRange)[] => {
const restrictedReads = settings?.nip42?.restrictedReads
if (!restrictedReads?.enabled) {
Expand Down
Loading
Loading