diff --git a/.changeset/nip42-session-manager.md b/.changeset/nip42-session-manager.md new file mode 100644 index 00000000..60b5ab77 --- /dev/null +++ b/.changeset/nip42-session-manager.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(nip42): add session tracking with optional TTL, write-time authRequired, and NIP-11 auth_required advertising diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4604edcc..072abff2 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 40e72178..0c5fc280 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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: diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 830a5dbe..08e41d3d 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -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 } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 6cfec3a9..ba0e859e 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,4 +1,3 @@ -import { randomBytes } from 'crypto' import cluster from 'cluster' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' @@ -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' @@ -35,8 +35,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map - private readonly challenge: string - private readonly authenticatedPubkeys: Set + private readonly session: Nip42SessionManager public constructor( private readonly client: WebSocket, @@ -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 { @@ -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 } @@ -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 { - 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) { @@ -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) { diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 75d62142..d54b74ec 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -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' @@ -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) @@ -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 { if (isProtectedEvent(event)) { if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) { diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index ae7d4257..47052d92 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -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, diff --git a/src/routes/index.ts b/src/routes/index.ts index fece09bf..d6d91002 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -13,6 +13,8 @@ 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) @@ -20,6 +22,7 @@ router.use((req, res, next) => { next() }) +// codeql[js/missing-rate-limiting] router.get('/', rootRequestHandler) router.get('/healthz', getHealthRequestHandler) router.get('/terms', getTermsRequestHandler) diff --git a/src/utils/nip42-session.ts b/src/utils/nip42-session.ts new file mode 100644 index 00000000..454dc518 --- /dev/null +++ b/src/utils/nip42-session.ts @@ -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() + + 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 { + 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) + } + } + } +} diff --git a/src/utils/nip42.ts b/src/utils/nip42.ts index a8dedf0e..14469543 100644 --- a/src/utils/nip42.ts +++ b/src/utils/nip42.ts @@ -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) { diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index c59e9f83..7ba82840 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -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', () => { @@ -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', () => { @@ -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, diff --git a/test/unit/cli/info.spec.ts b/test/unit/cli/info.spec.ts index 7bf9b892..5db1a4b5 100644 --- a/test/unit/cli/info.spec.ts +++ b/test/unit/cli/info.spec.ts @@ -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') diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index a991b472..71b48890 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -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' diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index 569151b9..5c4f3648 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -211,6 +211,19 @@ describe('rootRequestHandler', () => { expect(doc.limitation.search_supported).to.equal(true) }) + it('sets limitation.auth_required from nip42.authRequired', () => { + createSettingsStub.returns(baseSettings) + rootRequestHandler(req, res, next) + expect(res.send.firstCall.args[0].limitation.auth_required).to.equal(false) + + createSettingsStub.returns({ + ...baseSettings, + nip42: { authRequired: true }, + }) + rootRequestHandler(req, res, next) + expect(res.send.secondCall.args[0].limitation.auth_required).to.equal(true) + }) + it('sets limitation.restricted_writes based on active write restrictions', () => { rootRequestHandler(req, res, next) const defaultDoc = res.send.firstCall.args[0] diff --git a/test/unit/utils/nip42-session.spec.ts b/test/unit/utils/nip42-session.spec.ts new file mode 100644 index 00000000..1dac201a --- /dev/null +++ b/test/unit/utils/nip42-session.spec.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai' + +import { Nip42SessionManager } from '../../../src/utils/nip42-session' + +describe('Nip42SessionManager', () => { + it('issues a non-empty challenge on construction', () => { + const session = new Nip42SessionManager() + expect(session.getChallenge()).to.be.a('string').with.length.greaterThan(0) + }) + + it('rotateChallenge replaces the active challenge', () => { + const session = new Nip42SessionManager() + const previous = session.getChallenge() + const next = session.rotateChallenge() + + expect(next).to.be.a('string').with.length.greaterThan(0) + expect(next).not.to.equal(previous) + expect(session.getChallenge()).to.equal(next) + }) + + it('authenticate adds a pubkey to the session', () => { + const session = new Nip42SessionManager() + const pubkey = 'a'.repeat(64) + + session.authenticate(pubkey, 1_700_000_000) + + expect(session.isAuthenticated(pubkey, 1_700_000_000)).to.equal(true) + expect(session.getAuthenticatedPubkeys(1_700_000_000).has(pubkey)).to.equal(true) + expect(session.getSession(pubkey, 1_700_000_000)).to.deep.equal({ + pubkey, + authenticatedAt: 1_700_000_000, + }) + }) + + it('supports multiple authenticated pubkeys', () => { + const session = new Nip42SessionManager() + const pk1 = 'a'.repeat(64) + const pk2 = 'b'.repeat(64) + + session.authenticate(pk1) + session.authenticate(pk2) + + const pubkeys = session.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(2) + expect(pubkeys.has(pk1)).to.equal(true) + expect(pubkeys.has(pk2)).to.equal(true) + }) + + it('clear removes one pubkey or the whole session', () => { + const session = new Nip42SessionManager() + const pk1 = 'a'.repeat(64) + const pk2 = 'b'.repeat(64) + session.authenticate(pk1) + session.authenticate(pk2) + + session.clear(pk1) + expect(session.isAuthenticated(pk1)).to.equal(false) + expect(session.isAuthenticated(pk2)).to.equal(true) + + session.clear() + expect(session.getAuthenticatedPubkeys().size).to.equal(0) + }) + + it('does not expire sessions when TTL is unset or non-positive', () => { + const unsetTtl = new Nip42SessionManager(() => undefined) + const zeroTtl = new Nip42SessionManager(() => 0) + const pubkey = 'a'.repeat(64) + + unsetTtl.authenticate(pubkey, 100) + zeroTtl.authenticate(pubkey, 100) + + expect(unsetTtl.isAuthenticated(pubkey, 1_000_000)).to.equal(true) + expect(zeroTtl.isAuthenticated(pubkey, 1_000_000)).to.equal(true) + }) + + it('expires sessions after the configured TTL', () => { + const session = new Nip42SessionManager(() => 60) + const pubkey = 'a'.repeat(64) + + session.authenticate(pubkey, 1000) + + expect(session.isAuthenticated(pubkey, 1059)).to.equal(true) + expect(session.isAuthenticated(pubkey, 1060)).to.equal(false) + expect(session.getAuthenticatedPubkeys(1060).size).to.equal(0) + }) +}) diff --git a/test/unit/utils/nip42.spec.ts b/test/unit/utils/nip42.spec.ts index b0c20c72..efba87f8 100644 --- a/test/unit/utils/nip42.spec.ts +++ b/test/unit/utils/nip42.spec.ts @@ -4,6 +4,7 @@ import { createReadAuthorizationGuard, DEFAULT_RESTRICTED_READ_KINDS, getRestrictedReadKinds, + isAuthRequired, isClientAuthorizedToReadMention, isCountAuthorized, isSubscriptionAuthRequired, @@ -38,6 +39,17 @@ const enabledSettings = (kinds?: (number | [number, number])[]): Settings => }) as unknown as Settings describe('nip42', () => { + describe('isAuthRequired', () => { + it('returns false when unset or disabled', () => { + expect(isAuthRequired(undefined)).to.equal(false) + expect(isAuthRequired({} as Settings)).to.equal(false) + expect(isAuthRequired({ nip42: { authRequired: false } } as Settings)).to.equal(false) + }) + + it('returns true when enabled', () => { + expect(isAuthRequired({ nip42: { authRequired: true } } as Settings)).to.equal(true) + }) + }) describe('getRestrictedReadKinds', () => { it('returns empty array when settings are undefined', () => { expect(getRestrictedReadKinds(undefined)).to.deep.equal([])