Skip to content
Open
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-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, write-time authRequired, and NIP-11 auth_required advertising
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.authRequired | When true, clients must NIP-42 AUTH as the event author before publishing events. Advertised in NIP-11 as `limitation.auth_required`. Defaults to false. |
| nip42.sessionTtl | 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). |
| nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. |
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.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.
sessionTtl: 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
10 changes: 10 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,16 @@ export interface Nip42RestrictedReads {
}

export interface Nip42Settings {
/**
* When true, clients must NIP-42 AUTH as the event author before publishing.
* Also advertised via NIP-11 `limitation.auth_required`.
*/
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).
*/
sessionTtl?: number
restrictedReads?: Nip42RestrictedReads
}

Expand Down
24 changes: 12 additions & 12 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?.sessionTtl)
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)
// Keep the existing challenge. NIP-42 allows multiple AUTH events on one
// socket to share it; rotating here would break pipelined multi-pubkey auth.
this.session.authenticate(pubkey)
}

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
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
2 changes: 1 addition & 1 deletion src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ 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,
auth_required: false,
auth_required: settings.nip42?.authRequired === true,
payment_required: settings.payments?.enabled,
created_at_lower_limit: createdAtLimits?.maxNegativeDelta,
created_at_upper_limit: createdAtLimits?.maxPositiveDelta,
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
77 changes: 77 additions & 0 deletions src/utils/nip42-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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).
*/
export class Nip42SessionManager {
private challenge: string
private readonly sessions = new Map<string, Nip42Session>()

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
}

public authenticate(pubkey: Pubkey, now = Math.floor(Date.now() / 1000)): void {
this.sessions.set(pubkey, { pubkey, authenticatedAt: now })
}

public clear(pubkey?: Pubkey): void {
if (typeof pubkey === 'undefined') {
this.sessions.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
21 changes: 20 additions & 1 deletion test/unit/adapters/web-socket-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,13 +768,18 @@ describe('WebSocketAdapter', () => {
expect(pubkeys.size).to.equal(0)
})

it('addAuthenticatedPubkey adds a pubkey', () => {
it('addAuthenticatedPubkey adds a pubkey without rotating the challenge', () => {
const pubkey = 'a'.repeat(64)
const previousChallenge = adapter.getChallenge()
const sendCallsBefore = (client.send as Sinon.SinonStub).callCount

adapter.addAuthenticatedPubkey(pubkey)

const pubkeys = adapter.getAuthenticatedPubkeys()
expect(pubkeys.size).to.equal(1)
expect(pubkeys.has(pubkey)).to.be.true
expect(adapter.getChallenge()).to.equal(previousChallenge)
expect((client.send as Sinon.SinonStub).callCount).to.equal(sendCallsBefore)
})

it('addAuthenticatedPubkey supports multiple pubkeys', () => {
Expand All @@ -787,6 +792,8 @@ describe('WebSocketAdapter', () => {
expect(pubkeys.size).to.equal(2)
expect(pubkeys.has(pk1)).to.be.true
expect(pubkeys.has(pk2)).to.be.true
// Same challenge must remain valid for subsequent AUTH messages (NIP-42).
expect(adapter.getChallenge()).to.equal(adapter.getChallenge())
})

it('addAuthenticatedPubkey deduplicates same pubkey', () => {
Expand All @@ -798,6 +805,18 @@ describe('WebSocketAdapter', () => {
expect(pubkeys.size).to.equal(1)
})

it('expires authenticated pubkeys after sessionTtl', () => {
const clock = sandbox.useFakeTimers({ now: 1_700_000_000_000 })
settingsFactory.returns({ nip42: { sessionTtl: 60 } })

const pubkey = 'a'.repeat(64)
adapter.addAuthenticatedPubkey(pubkey)
expect(adapter.getAuthenticatedPubkeys().has(pubkey)).to.be.true

clock.tick(60_000)
expect(adapter.getAuthenticatedPubkeys().has(pubkey)).to.be.false
})

it('generates different challenges for different adapters', () => {
const adapter2 = new WebSocketAdapter(
client,
Expand Down
8 changes: 4 additions & 4 deletions test/unit/cli/info.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const { expect } = require('chai')
const fs = require('fs')
const path = require('path')
const sinon = require('sinon')
import { expect } from 'chai'
import fs from 'fs'
import path from 'path'
import sinon from 'sinon'

const infoCommand = require('../../../dist/src/cli/commands/info.js')
const configUtils = require('../../../dist/src/cli/utils/config.js')
Expand Down
49 changes: 49 additions & 0 deletions test/unit/handlers/event-message-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2202,6 +2202,55 @@ describe('EventMessageHandler', () => {
})
})

describe('isAuthenticationRequired', () => {
it('returns undefined when authRequired is disabled', () => {
handler = new EventMessageHandler(
{ getAuthenticatedPubkeys: () => new Set() } as any,
() => null,
{} as any,
userRepository,
() => ({ info: { relay_url: 'relay_url' }, nip42: { authRequired: false } }) as any,
{} as any,
{ hasKey: async () => false, setKey: async () => true } as any,
() => ({ hit: async () => false }),
)

expect((handler as any).isAuthenticationRequired(event)).to.be.undefined
})

it('returns auth-required when enabled and the author is not authenticated', () => {
handler = new EventMessageHandler(
{ getAuthenticatedPubkeys: () => new Set() } as any,
() => null,
{} as any,
userRepository,
() => ({ info: { relay_url: 'relay_url' }, nip42: { authRequired: true } }) as any,
{} as any,
{ hasKey: async () => false, setKey: async () => true } as any,
() => ({ hit: async () => false }),
)

expect((handler as any).isAuthenticationRequired(event)).to.equal(
'auth-required: authentication is required to publish events',
)
})

it('returns undefined when enabled and the author is authenticated', () => {
handler = new EventMessageHandler(
{ getAuthenticatedPubkeys: () => new Set([event.pubkey]) } as any,
() => null,
{} as any,
userRepository,
() => ({ info: { relay_url: 'relay_url' }, nip42: { authRequired: true } }) as any,
{} as any,
{ hasKey: async () => false, setKey: async () => true } as any,
() => ({ hit: async () => false }),
)

expect((handler as any).isAuthenticationRequired(event)).to.be.undefined
})
})

describe('isProtectedEventBlocked', () => {
const PRIVKEY = '0000000000000000000000000000000000000000000000000000000000000001'

Expand Down
Loading
Loading