diff --git a/.changeset/nip42-session-manager.md b/.changeset/nip42-session-manager.md new file mode 100644 index 00000000..b5573d71 --- /dev/null +++ b/.changeset/nip42-session-manager.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(nip42): add session tracking with optional TTL and publish-time authRequired (NIP-11 restricted_writes) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 3e5ff311..7dcdc996 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -188,6 +188,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.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 9eba3b9f..2e8858b7 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.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. + 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/adapters.ts b/src/@types/adapters.ts index c42bc0a8..433f4e73 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -17,7 +17,8 @@ export type IWebSocketAdapter = EventEmitter & { getSubscriptions(): Map getChallenge(): string getAuthenticatedPubkeys(): ReadonlySet - 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 { diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 05fa0a8d..eb170002 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -325,6 +325,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). + */ + sessionTtl?: number restrictedReads?: Nip42RestrictedReads } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 6cfec3a9..00fe1394 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) + 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) { @@ -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/auth-message-handler.ts b/src/handlers/auth-message-handler.ts index e3f4f0c1..538f412f 100644 --- a/src/handlers/auth-message-handler.ts +++ b/src/handlers/auth-message-handler.ts @@ -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, '') } 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..24704a31 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -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 || @@ -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, 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..8176ec5f --- /dev/null +++ b/src/utils/nip42-session.ts @@ -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 sessionTtl. + */ +export class Nip42SessionManager { + private challenge: string + private readonly sessions = new Map() + private readonly acceptedAuthEventIds = new Set() + + 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 { + 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..1f03cc1d 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -317,7 +317,7 @@ describe('WebSocketAdapter', () => { it('does not send restricted-kind event to a client authenticated as somebody else', () => { settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) client.readyState = WebSocket.OPEN - adapter.addAuthenticatedPubkey('e'.repeat(64)) + adapter.addAuthenticatedPubkey('e'.repeat(64), '1'.repeat(64)) adapter.onSubscribed('sub-1', [{ kinds: [1059] }]) const event = { @@ -339,7 +339,7 @@ describe('WebSocketAdapter', () => { const recipient = 'd'.repeat(64) settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) client.readyState = WebSocket.OPEN - adapter.addAuthenticatedPubkey(recipient) + adapter.addAuthenticatedPubkey(recipient, '1'.repeat(64)) adapter.onSubscribed('sub-1', [{ kinds: [1059] }]) const event = { @@ -364,7 +364,7 @@ describe('WebSocketAdapter', () => { const author = 'b'.repeat(64) settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) client.readyState = WebSocket.OPEN - adapter.addAuthenticatedPubkey(author) + adapter.addAuthenticatedPubkey(author, '1'.repeat(64)) adapter.onSubscribed('sub-1', [{ kinds: [4] }]) const event = { @@ -768,36 +768,63 @@ 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) - adapter.addAuthenticatedPubkey(pubkey) + const previousChallenge = adapter.getChallenge() + const sendCallsBefore = (client.send as Sinon.SinonStub).callCount + + expect(adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64))).to.equal(true) 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', () => { const pk1 = 'a'.repeat(64) const pk2 = 'b'.repeat(64) - adapter.addAuthenticatedPubkey(pk1) - adapter.addAuthenticatedPubkey(pk2) + const challengeBefore = adapter.getChallenge() + expect(adapter.addAuthenticatedPubkey(pk1, '1'.repeat(64))).to.equal(true) + expect(adapter.addAuthenticatedPubkey(pk2, '2'.repeat(64))).to.equal(true) const pubkeys = adapter.getAuthenticatedPubkeys() 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(challengeBefore) }) it('addAuthenticatedPubkey deduplicates same pubkey', () => { const pubkey = 'a'.repeat(64) - adapter.addAuthenticatedPubkey(pubkey) - adapter.addAuthenticatedPubkey(pubkey) + expect(adapter.addAuthenticatedPubkey(pubkey, '1'.repeat(64))).to.equal(true) + expect(adapter.addAuthenticatedPubkey(pubkey, '2'.repeat(64))).to.equal(true) const pubkeys = adapter.getAuthenticatedPubkeys() expect(pubkeys.size).to.equal(1) }) + it('rejects replayed AUTH event ids', () => { + const pubkey = 'a'.repeat(64) + const eventId = '1'.repeat(64) + expect(adapter.addAuthenticatedPubkey(pubkey, eventId)).to.equal(true) + expect(adapter.addAuthenticatedPubkey(pubkey, eventId)).to.equal(false) + }) + + 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, '1'.repeat(64)) + 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/handlers/auth-message-handler.spec.ts b/test/unit/handlers/auth-message-handler.spec.ts index 66edd38b..e9b6d176 100644 --- a/test/unit/handlers/auth-message-handler.spec.ts +++ b/test/unit/handlers/auth-message-handler.spec.ts @@ -80,7 +80,7 @@ describe('AuthMessageHandler', () => { getSubscriptions: Sinon.stub().returns(new Map()), getChallenge: Sinon.stub().returns(challenge), getAuthenticatedPubkeys: Sinon.stub().returns(new Set()), - addAuthenticatedPubkey: Sinon.stub(), + addAuthenticatedPubkey: Sinon.stub().returns(true), } as any as IWebSocketAdapter settingsFactory = Sinon.stub().returns({ @@ -100,7 +100,7 @@ describe('AuthMessageHandler', () => { await handler.handleMessage(message) - expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey, message[1].id) expect(emitStub).to.have.been.calledOnce const args = emitStub.firstCall.args expect(args[0]).to.equal(WebSocketAdapterEvent.Message) @@ -211,7 +211,7 @@ describe('AuthMessageHandler', () => { await handler.handleMessage(message) - expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey, message[1].id) const args = emitStub.firstCall.args expect(args[1][2]).to.equal(true) }) @@ -224,10 +224,22 @@ describe('AuthMessageHandler', () => { await handler.handleMessage(message) - expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey, message[1].id) } finally { clock.restore() } }) + + it('rejects when the AUTH event id was already accepted on this socket', async () => { + const message = await createAuthEvent() + ;(webSocket.addAuthenticatedPubkey as Sinon.SinonStub).returns(false) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey, message[1].id) + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('auth event already used') + }) }) }) 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..86d71999 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,21 @@ describe('rootRequestHandler', () => { expect(doc.limitation.search_supported).to.equal(true) }) + it('keeps limitation.auth_required false for publish-only 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) + // NIP-11 auth_required means AUTH before any action; publish-only stays false. + expect(res.send.secondCall.args[0].limitation.auth_required).to.equal(false) + expect(res.send.secondCall.args[0].limitation.restricted_writes).to.equal(true) + }) + it('sets limitation.restricted_writes based on active write restrictions', () => { rootRequestHandler(req, res, next) const defaultDoc = res.send.firstCall.args[0] @@ -225,6 +240,15 @@ describe('rootRequestHandler', () => { expect(restrictedDoc.limitation.restricted_writes).to.equal(true) }) + it('sets limitation.restricted_writes when nip42.authRequired is enabled', () => { + createSettingsStub.returns({ + ...baseSettings, + nip42: { authRequired: true }, + }) + rootRequestHandler(req, res, next) + expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) + }) + it('returns empty fees instead of crashing when the payments block is absent', () => { const { payments: _payments, ...settingsWithoutPayments } = baseSettings createSettingsStub.returns(settingsWithoutPayments) diff --git a/test/unit/utils/nip42-session.spec.ts b/test/unit/utils/nip42-session.spec.ts new file mode 100644 index 00000000..091952a7 --- /dev/null +++ b/test/unit/utils/nip42-session.spec.ts @@ -0,0 +1,106 @@ +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) + + expect(session.authenticate(pubkey, '1'.repeat(64), 1_700_000_000)).to.equal(true) + + 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('rejects replayed AUTH event ids', () => { + const session = new Nip42SessionManager() + const pubkey = 'a'.repeat(64) + const eventId = '1'.repeat(64) + + expect(session.authenticate(pubkey, eventId, 1000)).to.equal(true) + expect(session.authenticate(pubkey, eventId, 1060)).to.equal(false) + expect(session.getSession(pubkey, 1060)?.authenticatedAt).to.equal(1000) + }) + + it('supports multiple authenticated pubkeys', () => { + const session = new Nip42SessionManager() + const pk1 = 'a'.repeat(64) + const pk2 = 'b'.repeat(64) + + expect(session.authenticate(pk1, '1'.repeat(64))).to.equal(true) + expect(session.authenticate(pk2, '2'.repeat(64))).to.equal(true) + + 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, '1'.repeat(64)) + session.authenticate(pk2, '2'.repeat(64)) + + 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, '1'.repeat(64), 100) + zeroTtl.authenticate(pubkey, '2'.repeat(64), 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, '1'.repeat(64), 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) + }) + + it('does not extend TTL when the same AUTH event is replayed', () => { + const session = new Nip42SessionManager(() => 60) + const pubkey = 'a'.repeat(64) + const eventId = '1'.repeat(64) + + expect(session.authenticate(pubkey, eventId, 1000)).to.equal(true) + expect(session.authenticate(pubkey, eventId, 1050)).to.equal(false) + expect(session.isAuthenticated(pubkey, 1060)).to.equal(false) + }) +}) 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([])