diff --git a/.changeset/nip43-invite-requests.md b/.changeset/nip43-invite-requests.md new file mode 100644 index 00000000..a1327249 --- /dev/null +++ b/.changeset/nip43-invite-requests.md @@ -0,0 +1,35 @@ +--- +"nostream": minor +--- + +feat(nip43): issue kind 28935 invite codes on request + +NIP-43 kind 28935 is not an event clients publish — it is a REQ the relay answers by +minting an invite code on the fly and returning a relay-signed ephemeral event. Nostream +now serves those subscriptions, completing the membership flow: request a claim, join with +kind 28934, publish. + +Off by default. It requires `nip43.enabled` and the new `nip43.allowInviteRequests`, a +NIP-42 authenticated requester, an `info.self` consistent with the relay signing key, and a +per-pubkey budget under the new `limits.invite.rateLimits` (5/hour by default). This also +makes the previously inert `nip43.inviteRequestWhitelist` setting take effect. The minted +event is never persisted and never broadcast: the claim tag is a bearer secret and is sent +only to the socket that asked for it. + +Two fixes the flow depended on. The relay signs its own events with a key derived from +`SECRET`, but `info.self` was a hand-edited string that nothing validated — by default it +was a placeholder that is not a pubkey at all, so any NIP-43 client verifying a relay-signed +event against `self` would reject it. `info.self` is now optional: when unset or unparseable, +NIP-11 advertises the derived signing pubkey instead, and `nostream info` prints that pubkey +so operators can pin it. + +Kind 28935 also sits in the ephemeral range, so a client-published one fell through to +`EphemeralEventStrategy` and was broadcast to every subscriber — including everyone +subscribed to kind 28935 waiting for a real invite. Anyone could inject a forged `claim` tag +into that subscription. It is now rejected with an `OK` false and never broadcast, and +bypasses the NIP-43 admission gate so that rejection actually reaches non-members, who are +the ones most likely to publish it by mistake while trying to obtain a code. + +CLI.md and README.md now describe the request flow. CLI.md previously claimed the relay +"does not yet generate kind 28935 on `REQ`", and never mentioned that `nostream info` +prints the signing pubkey that CONFIGURATION.md tells operators to pin. diff --git a/CLI.md b/CLI.md index de76dcd7..80d80bc2 100644 --- a/CLI.md +++ b/CLI.md @@ -46,7 +46,20 @@ docker compose exec nostream node src/cli/index.js invite create `--uses` defaults to `nip43.defaultMaxUses` (1). `--expires-in` defaults to `nip43.inviteCodeExpirySeconds` (600 = 10 minutes). `--expires-in` must be a positive integer; never-expiring codes are a yaml policy (`nip43.inviteCodeExpirySeconds: 0`), not a CLI flag. The printed code is the first line of human output so scripts can capture it. If `info.self` is a hex pubkey or `npub1…`, it is stored as `created_by`. -This does not yet generate kind 28935 on `REQ` or publish membership list events. +The relay also answers `REQ`s for kind 28935 by minting a code on the fly and returning it as a relay-signed ephemeral event, so users can obtain a claim string without an operator handing one out. It is off by default and requires `nip43.enabled`, `nip43.allowInviteRequests` and a NIP-42 authenticated client — see [CONFIGURATION.md](CONFIGURATION.md). Membership list events (kind 13534) are not published yet. + +Invites minted over `REQ` record the requesting pubkey as `created_by`; `nostream invite create` records `info.self`. + +### Relay signing pubkey + +NIP-43 clients verify relay-signed events against the `self` field of the NIP-11 document, so `self` must match the key the relay actually signs with. That key is derived from `SECRET` and is otherwise invisible, so `nostream info` prints it: + +```bash +nostream info | grep 'Signing pubkey' +nostream info --json # same value under relay.signingPubkey +``` + +Leave `info.self` unset in settings to have this value advertised automatically. Set it only if you want to pin it explicitly, and set it to exactly this value — a mismatch disables kind 28935 invite requests. ## Removed Legacy Wrappers diff --git a/CONFIGURATION.md b/CONFIGURATION.md index f47212af..c475d498 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -148,7 +148,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta | info.name | Public name of your relay. (e.g. TBG's Public Relay) | | info.pubkey | Relay operator's Nostr pubkey in hex format. | | info.relay_url | Public-facing URL of your relay. (e.g. wss://relay.your-domain.com) | -| info.self | Relay pubkey in hex format for the relay information document `self` field. | +| info.self | Optional. The relay's own signing pubkey (hex or `npub1...`), published as `self` in the relay information document. NIP-43 clients verify relay-signed events against it, so it must match the key the relay signs with. Leave unset to derive it from `SECRET`; run `nostream info` to print the derived value. A configured value that does not match disables kind 28935 invite requests. | | info.terms_of_service | Public URL to relay terms of service. | | limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | | limits.admissionCheck.rateLimits[].period | Rate limit period in milliseconds. | @@ -173,6 +173,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.event.retention.pubkey.whitelist | Public keys excluded from retention purge. | | limits.event.whitelists.ipAddresses | List of IPs (IPv4 or IPv6) to ignore rate limits. | | limits.event.whitelists.pubkeys | List of public keys to ignore rate limits. | +| limits.invite.rateLimits[].period | Rate limit period in milliseconds for NIP-43 kind 28935 invite requests, counted per requesting pubkey. | +| limits.invite.rateLimits[].rate | Maximum number of invite requests a single pubkey may make during period. Each granted request writes a row to `invite_codes`, so this is the main defence against a code flood. Defaults to 5 per hour. | | limits.message.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | | limits.message.rateLimits[].period | Rate limit period in milliseconds. | | limits.client.subscription.maxSubscriptions | Maximum number of subscriptions per connected client. Defaults to 10. Disabled when set to zero. | @@ -199,6 +201,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip43.enabled | Enable NIP-43 invite-based membership. When true, only admitted members may publish. Defaults to false. | | nip43.inviteCodeExpirySeconds | Seconds until a newly minted invite code expires. `0` means the code never expires. Defaults to 600 (10 minutes). | | nip43.defaultMaxUses | How many times a newly minted invite code can be claimed. Defaults to 1. | +| nip43.allowInviteRequests | Answer REQs for kind 28935 by minting an invite code on the fly and returning it as a relay-signed ephemeral event on that subscription. NIP-43 requires relays to opt in to this explicitly. Requesters must be authenticated via NIP-42, and `info.self` must match the relay's signing pubkey. Defaults to false. | +| nip43.inviteRequestWhitelist | Public keys allowed to request kind 28935 invite codes. Empty (the default) means any authenticated pubkey may request one; a non-empty list restricts minting to those pubkeys. | | nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. | | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | | nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | diff --git a/README.md b/README.md index 8f3c6c9d..a67c2423 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,11 @@ Kind 28934 join requests are implemented. Mint a code and share it out of band: docker compose exec nostream node src/cli/index.js invite create ``` -See [CLI.md](CLI.md) for `--uses` / `--expires-in` and Docker vs local Postgres. +Users can also request a code themselves: the relay answers a `REQ` for kind 28935 by +minting one on the fly. Off by default — set `nip43.allowInviteRequests` to enable it. + +See [CLI.md](CLI.md) for `--uses` / `--expires-in` and Docker vs local Postgres, and +[CONFIGURATION.md](CONFIGURATION.md) for the invite request settings. ### Running as a Service diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 6cf4d096..04f58884 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -5,7 +5,11 @@ info: banner: https://nostream.your-domain.com/banner.png icon: https://nostream.your-domain.com/icon.png pubkey: replace-with-your-pubkey-in-hex - self: replace-with-your-relay-pubkey-in-hex + # Optional. The relay's own signing pubkey, published as `self` in the NIP-11 + # document. NIP-43 clients verify relay-signed events against it, so it MUST + # match the key the relay signs with. Leave it unset to have it derived from + # SECRET automatically; run `nostream info` to see the derived value. + # self: replace-with-your-relay-pubkey-in-hex contact: mailto:operator@your-domain.com terms_of_service: https://nostream.your-domain.com/terms payments: @@ -84,6 +88,13 @@ nip43: inviteCodeExpirySeconds: 600 # How many times a newly minted invite code can be claimed. defaultMaxUses: 1 + # Answer REQs for kind 28935 by minting an invite code on the fly and returning + # it as a relay-signed ephemeral event. NIP-43 requires relays to opt in to this + # explicitly. Requesters must be authenticated via NIP-42. + allowInviteRequests: false + # Pubkeys allowed to request invite codes. Empty means any authenticated pubkey + # may request one; non-empty restricts minting to the listed pubkeys. + inviteRequestWhitelist: [] nip45: enabled: true nip50: @@ -148,6 +159,13 @@ limits: - "::1" - "10.10.10.1" - "::ffff:10.10.10.1" + invite: + # Per-pubkey limit on kind 28935 invite requests. Each mint writes a row to + # invite_codes, so this is the main defence against a code flood. + rateLimits: + - description: 5 invite requests per hour per pubkey + period: 3600000 + rate: 5 admissionCheck: rateLimits: - description: 30 admission checks/min or 1 check every 2 seconds diff --git a/src/@types/settings.ts b/src/@types/settings.ts index eabf2470..c1c67d70 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -141,6 +141,10 @@ export interface AdmissionCheckLimits { ipWhitelist?: string[] } +export interface InviteLimits { + rateLimits?: RateLimit[] +} + export interface AdminLimits { rateLimits?: RateLimit[] loginRateLimits?: RateLimit[] @@ -150,6 +154,7 @@ export interface AdminLimits { export interface Limits { rateLimiter?: RateLimiterSettings invoice?: InvoiceLimits + invite?: InviteLimits admissionCheck?: AdmissionCheckLimits admin?: AdminLimits connection?: ConnectionLimits diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index 9b162ff8..b4e43b92 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -3,6 +3,7 @@ import knex from 'knex' import packageJson from '../../../package.json' import { loadMergedSettings } from '../utils/config' +import { tryGetRelayNip43Pubkey } from '../../utils/nip43' import { logError, logInfo } from '../utils/output' import { getOnionKeyPath, getTorHostnamePath } from '../utils/bootstrap' import { getProjectPath } from '../utils/paths' @@ -127,6 +128,9 @@ export const getInfoPayload = async () => { name: settings.info?.name, url: settings.info?.relay_url, pubkey: settings.info?.pubkey, + // The key the relay signs its own events with, and what NIP-11 `self` must + // be set to. Derived from SECRET, so it is otherwise invisible to operators. + signingPubkey: tryGetRelayNip43Pubkey(settings) ?? null, paymentsEnabled: settings.payments?.enabled ?? false, paymentProcessor: settings.payments?.processor ?? null, }, @@ -236,6 +240,7 @@ export const runInfo = async (options: InfoOptions): Promise => { logInfo(`Nostream v${payload.version}`) logInfo(`Relay: ${payload.relay.name ?? 'n/a'} (${payload.relay.url ?? 'n/a'})`) logInfo(`Pubkey: ${payload.relay.pubkey ?? 'n/a'}`) + logInfo(`Signing pubkey (NIP-11 self): ${payload.relay.signingPubkey ?? 'unavailable (SECRET not set)'}`) logInfo(`Payments: ${payload.relay.paymentsEnabled ? `enabled (${payload.relay.paymentProcessor})` : 'disabled'}`) logInfo(`Tor hostname: ${payload.tor.hostname ?? 'not found'}`) logInfo(`Onion key path: ${payload.tor.onionPrivateKeyPath}`) diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 37f67e9c..ef61a7cb 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -11,7 +11,7 @@ import { isReplaceableEvent, isRequestToVanishEvent, } from '../utils/event' -import { isNip43JoinRequest, isNip43LeaveRequest } from '../utils/nip43' +import { isNip43InviteRequest, isNip43JoinRequest, isNip43LeaveRequest } from '../utils/nip43' import { isRelayListEvent } from '../utils/nip65' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' @@ -22,6 +22,7 @@ import { Factory } from '../@types/base' import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy' import { GroupEventStrategy } from '../handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../@types/message-handlers' +import { InviteRequestEventStrategy } from '../handlers/event-strategies/invite-request-event-strategy' import { JoinRequestEventStrategy } from '../handlers/event-strategies/join-request-event-strategy' import { LeaveRequestEventStrategy } from '../handlers/event-strategies/leave-request-event-strategy' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -50,12 +51,15 @@ export const eventStrategyFactory = return new TimestampEventStrategy(adapter, eventRepository) } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) - // NIP-43: Join/Leave requests MUST be checked before the generic ephemeral - // handler, because kinds 28934/28936 fall in the ephemeral range (20000-29999). + // NIP-43: Join/Leave/Invite requests MUST be checked before the generic + // ephemeral handler, because kinds 28934/28935/28936 fall in the ephemeral + // range (20000-29999) and would otherwise be broadcast to every subscriber. } else if (isNip43JoinRequest(event)) { return new JoinRequestEventStrategy(adapter, inviteCodeRepository, userRepository, cache, settings) } else if (isNip43LeaveRequest(event)) { return new LeaveRequestEventStrategy(adapter, userRepository, cache, settings) + } else if (isNip43InviteRequest(event)) { + return new InviteRequestEventStrategy(adapter) // NIP-90: DVM job requests (kind 5000-5999) checked early, same reasoning // as the NIP-43 checks above — kept explicit rather than relying on it // falling through to DefaultEventStrategy. diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 6981a194..3bf1a3f5 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -56,7 +56,13 @@ export const messageHandlerFactory = ) } case MessageType.REQ: - return new SubscribeMessageHandler(adapter, eventRepository, createSettings) + return new SubscribeMessageHandler( + adapter, + eventRepository, + createSettings, + inviteCodeRepository, + rateLimiterFactory, + ) case MessageType.CLOSE: return new UnsubscribeMessageHandler(adapter) case MessageType.COUNT: diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index d54b74ec..fc58b8ef 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -411,8 +411,17 @@ export class EventMessageHandler implements IMessageHandler { return } - // NIP-43: join/leave requests must bypass admission — they ARE the admission flow - if (event.kind === EventKinds.NIP43_JOIN_REQUEST || event.kind === EventKinds.NIP43_LEAVE_REQUEST) { + // NIP-43: join/leave requests must bypass admission — they ARE the admission + // flow. Invite requests bypass it too, not because they are valid to publish + // (they never are) but so InviteRequestEventStrategy can tell the client to + // use a REQ instead. Without this the people most likely to get 28935 wrong — + // non-members trying to obtain a code — are the only ones who never see that + // message. + if ( + event.kind === EventKinds.NIP43_JOIN_REQUEST || + event.kind === EventKinds.NIP43_LEAVE_REQUEST || + event.kind === EventKinds.NIP43_INVITE_REQUEST + ) { return } diff --git a/src/handlers/event-strategies/invite-request-event-strategy.ts b/src/handlers/event-strategies/invite-request-event-strategy.ts new file mode 100644 index 00000000..b0120793 --- /dev/null +++ b/src/handlers/event-strategies/invite-request-event-strategy.ts @@ -0,0 +1,35 @@ +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('invite-request-event-strategy') + +// NIP-43 kind 28935 travels relay -> client only: a client asks for an invite with +// a REQ, and the relay answers with an event signed by the pubkey in `self`. A +// client-published 28935 is therefore always invalid. +// +// Rejecting it explicitly matters. 28935 falls in the ephemeral range, so without +// this it reaches EphemeralEventStrategy and gets broadcast to every subscriber — +// and the clients subscribed to kind 28935 are exactly the ones waiting for an +// invite. Anyone could inject a forged claim tag into that subscription. Spec +// compliant clients discard it by checking the pubkey against `self`, but the +// relay should not be relaying forgeries in the first place. +export class InviteRequestEventStrategy implements IEventStrategy> { + public constructor(private readonly webSocket: IWebSocketAdapter) {} + + public async execute(event: Event): Promise { + logger('rejecting client-published invite request from %s', event.pubkey) + + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createEventCommandResult( + event.id, + false, + 'invalid: kind 28935 is issued by the relay, request one with a REQ for kind 28935', + ), + ) + } +} diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index 24704a31..d965d66b 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -7,6 +7,7 @@ import { DEFAULT_FILTER_LIMIT } from '../../constants/base' import { fromBech32 } from '../../utils/transform' import { getTemplate } from '../../utils/template-cache' import { getPublicPathPrefix, joinPathPrefix } from '../../utils/http' +import { resolveRelaySelfPubkey } from '../../utils/nip43' import packageJson from '../../../package.json' export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => { @@ -45,7 +46,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N if (hasExplicitNostrJsonAcceptHeader(request)) { const { - info: { name, description, banner, icon, pubkey: rawPubkey, self: rawSelf, contact, relay_url, terms_of_service }, + info: { name, description, banner, icon, pubkey: rawPubkey, contact, relay_url, terms_of_service }, } = settings const paymentsUrl = new URL(relay_url) @@ -71,7 +72,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N (eventLimits?.kind?.blacklist?.length ?? 0) > 0 const pubkey = rawPubkey.startsWith('npub1') ? fromBech32(rawPubkey) : rawPubkey - const self = rawSelf?.startsWith('npub1') ? fromBech32(rawSelf) : rawSelf + // NIP-43 clients verify relay-signed events against `self`, so it must be the + // key the relay actually signs with. `info.self` is only authoritative when it + // parses; otherwise (unset, or still the placeholder) we advertise the derived + // signing pubkey, which is correct by construction. + const self = resolveRelaySelfPubkey(settings) const relayInformationDocument = { name, diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 1e095f78..0afa6257 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -9,13 +9,17 @@ import { createOutgoingEventMessage, } from '../utils/messages' import { createReadAuthorizationGuard, isSubscriptionAuthRequired } from '../utils/nip42' +import { getPublicKey, getRelayPrivateKey, isEventMatchingFilter, isExpiredEvent, toNostrEvent } from '../utils/event' import { IAbortable, IMessageHandler } from '../@types/message-handlers' -import { isEventMatchingFilter, isExpiredEvent, toNostrEvent } from '../utils/event' +import { IEventRepository, IInviteCodeRepository } from '../@types/repositories' +import { isNip43InviteRequestFilter, isRelaySelfConsistent } from '../utils/nip43' +import { buildInviteCodeEvent, issueInviteCode } from '../utils/nip43-invites' import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { createLogger } from '../factories/logger-factory' import { Event } from '../@types/event' -import { IEventRepository } from '../@types/repositories' +import { Factory, Pubkey } from '../@types/base' +import { IRateLimiter } from '../@types/utils' import { IWebSocketAdapter } from '../@types/adapters' import { Settings } from '../@types/settings' import { SubscribeMessage } from '../@types/messages' @@ -30,6 +34,8 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { private readonly webSocket: IWebSocketAdapter, private readonly eventRepository: IEventRepository, private readonly settings: () => Settings, + private readonly inviteCodeRepository: IInviteCodeRepository, + private readonly rateLimiter: Factory, ) { this.abortController = new AbortController() } @@ -65,9 +71,135 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, filters) + await this.maybeIssueInviteCode(subscriptionId, filters) + await this.fetchAndSend(subscriptionId, filters) } + // NIP-43: kind 28935 is never published by a client and never stored. A REQ for + // it asks the relay to mint an invite code, which we answer with a relay-signed + // ephemeral event on this socket only. fetchAndSend still runs afterwards: its + // query returns nothing for 28935 and its EOSE correctly marks the end of + // *stored* events. + private async maybeIssueInviteCode(subscriptionId: SubscriptionId, filters: SubscriptionFilter[]): Promise { + // uniqWith(equals) upstream only collapses identical filters, so + // [{kinds:[28935]},{kinds:[28935],limit:1}] survives as two. Mint once per REQ. + if (!filters.some(isNip43InviteRequestFilter)) { + return + } + + let event: Event | undefined + try { + event = await this.mintInviteCode() + } catch (error) { + // A failed mint must not abort the subscription: fall through to EOSE. + logger('unable to mint an invite code for subscription %s: %o', subscriptionId, error) + return + } + + if (!event) { + return + } + + // The claim tag is a bearer secret. Emit it on the requesting socket only — + // never Broadcast, never persisted. + this.webSocket.emit(WebSocketAdapterEvent.Message, createOutgoingEventMessage(subscriptionId, event)) + } + + private async mintInviteCode(): Promise { + const settings = this.settings() + + if (settings.nip43?.enabled !== true) { + return + } + + // NIP-43: relays "must explicitly opt-in to this behavior by generating + // claims on the fly when requested". + if (settings.nip43.allowInviteRequests !== true) { + return + } + + // Checked after the feature flags so a relay with NIP-43 off cannot be made to + // log on every probe. Order is irrelevant otherwise: every guard must pass. + let selfConsistent: boolean | undefined + try { + selfConsistent = isRelaySelfConsistent(settings) + } catch (error) { + logger('info.self is not a valid pubkey, refusing to issue invite codes: %o', error) + return + } + + if (selfConsistent === false) { + logger('info.self does not match the relay signing pubkey, refusing to issue invite codes') + return + } + + // Anonymous clients could otherwise farm codes, so NIP-42 auth is required. + const authenticatedPubkeys = [...this.webSocket.getAuthenticatedPubkeys()] + if (!authenticatedPubkeys.length) { + logger('ignoring invite request from an unauthenticated client') + return + } + + // An empty whitelist means "any authenticated pubkey may mint", not "nobody": + // allowInviteRequests already defaults to false, so an operator who turns the + // feature on without a whitelist plainly means self-serve invites. + const whitelist = settings.nip43.inviteRequestWhitelist + const isWhitelisted = Array.isArray(whitelist) && whitelist.length > 0 + const requester = isWhitelisted + ? authenticatedPubkeys.find((pubkey) => whitelist.includes(pubkey)) + : authenticatedPubkeys[0] + + if (!requester) { + logger('ignoring invite request: no authenticated pubkey is on inviteRequestWhitelist') + return + } + + if (await this.isInviteRequestRateLimited(requester)) { + return + } + + const relayPrivkey = getRelayPrivateKey(settings.info?.relay_url) + + // createdBy is the requester, for an audit trail of who minted what. Note this + // differs from `nostream invite create`, which records info.self. + const invite = await issueInviteCode(this.inviteCodeRepository, settings.nip43, { createdBy: requester }) + + return buildInviteCodeEvent(relayPrivkey, getPublicKey(relayPrivkey), invite) + } + + // Each mint writes a row to invite_codes, so this is scoped per pubkey. The + // generic per-IP message limiter is tuned for ordinary message volume and is + // not a substitute. + private async isInviteRequestRateLimited(pubkey: Pubkey): Promise { + const rateLimits = this.settings().limits?.invite?.rateLimits + + if (!Array.isArray(rateLimits) || !rateLimits.length) { + return false + } + + const rateLimiter = this.rateLimiter() + + for (const { period, rate } of rateLimits) { + let isRateLimited: boolean + try { + isRateLimited = await rateLimiter.hit(`${pubkey}:invites:${period}`, 1, { period, rate }) + } catch (error) { + // Fail closed: minting writes to the database, so an unavailable limiter + // must not leave the gate open. + logger('rate limiter unavailable for %s (%d/%d): %o', pubkey, rate, period, error) + return true + } + + if (isRateLimited) { + logger('rate limited %s: %d invite requests / %d ms exceeded', pubkey, rate, period) + return true + } + } + + return false + } + private async fetchAndSend(subscriptionId: string, filters: SubscriptionFilter[]): Promise { logger('fetching events for subscription %s with filters %o', subscriptionId, filters) const sendEvent = (event: Event) => diff --git a/src/utils/nip43-invites.ts b/src/utils/nip43-invites.ts index fc19e268..ec7cac8f 100644 --- a/src/utils/nip43-invites.ts +++ b/src/utils/nip43-invites.ts @@ -1,6 +1,11 @@ import { randomBytes } from 'crypto' +import { andThen, pipe } from 'ramda' import { CreateInviteCodeOptions, InviteCode } from '../@types/invite-code' +import { Event, UnidentifiedEvent } from '../@types/event' +import { EventKinds, EventTags } from '../constants/base' +import { Tag } from '../@types/base' +import { identifyEvent, signEvent } from './event' import { IInviteCodeRepository } from '../@types/repositories' import { Nip43Settings } from '../@types/settings' import { fromBech32 } from './transform' @@ -85,3 +90,39 @@ export const issueInviteCode = async ( createdBy, }) } + +/** + * Builds the relay-signed kind 28935 event that answers a NIP-43 invite request. + * + * The `claim` tag is a bearer secret: the caller MUST send this event only to the + * socket that asked for it, and MUST NOT persist or broadcast it. 28935 is in the + * ephemeral range, so there is nothing to store either way. + */ +export const buildInviteCodeEvent = async ( + relayPrivkey: string, + relayPubkey: string, + invite: Pick, + createdAt: number = Math.floor(Date.now() / 1000), +): Promise => { + const tags: Tag[] = [ + [EventTags.Claim, invite.code], + // NIP-70: this event is for the requesting client only. The tag carries no + // value, which Tag (which requires an index 1) cannot express. + [EventTags.Protected] as unknown as Tag, + ] + + // NIP-40: mirrors nip43.inviteCodeExpirySeconds so clients can show a countdown. + if (invite.expiresAt instanceof Date) { + tags.push([EventTags.Expiration, String(Math.floor(invite.expiresAt.getTime() / 1000))]) + } + + const unidentifiedEvent: UnidentifiedEvent = { + pubkey: relayPubkey, + kind: EventKinds.NIP43_INVITE_REQUEST, + created_at: createdAt, + content: '', + tags, + } + + return pipe(identifyEvent, andThen(signEvent(relayPrivkey)))(unidentifiedEvent) +} diff --git a/src/utils/nip43.ts b/src/utils/nip43.ts index 2ac3dc36..11d37796 100644 --- a/src/utils/nip43.ts +++ b/src/utils/nip43.ts @@ -1,5 +1,12 @@ +import { createLogger } from '../factories/logger-factory' import { Event } from '../@types/event' import { EventKinds, EventTags } from '../constants/base' +import { getPublicKey, getRelayPrivateKey } from './event' +import { parseRelayPubkey } from './nip43-invites' +import { Settings } from '../@types/settings' +import { SubscriptionFilter } from '../@types/subscription' + +const logger = createLogger('nip43') export const isNip43JoinRequest = (event: Event): boolean => event.kind === EventKinds.NIP43_JOIN_REQUEST @@ -7,6 +14,8 @@ export const isNip43JoinRequest = (event: Event): boolean => export const isNip43LeaveRequest = (event: Event): boolean => event.kind === EventKinds.NIP43_LEAVE_REQUEST +export const isNip43InviteRequest = (event: Event): boolean => event.kind === EventKinds.NIP43_INVITE_REQUEST + export const getClaimTag = (event: Event): string | undefined => { const tag = event.tags.find((t) => t.length >= 2 && t[0] === EventTags.Claim) return tag?.[1] @@ -18,3 +27,67 @@ const MAX_TIMESTAMP_DELTA_SECONDS = 600 export const isNip43RequestTimestampValid = (event: Event): boolean => Math.abs(Math.floor(Date.now() / 1000) - event.created_at) <= MAX_TIMESTAMP_DELTA_SECONDS + +// NIP-43 requires invite/membership events to be signed by the pubkey advertised +// as `self` in the relay's NIP-11 document. The relay signs with a key derived +// from SECRET, while `info.self` is hand-edited, so the two can silently diverge +// and every invite the relay mints becomes unverifiable. These helpers derive the +// real signing pubkey and let callers detect the mismatch. + +/** + * The relay's own signing pubkey, derived the same way every other relay-signed + * event derives it. Throws when SECRET is unset. + */ +export const getRelayNip43Pubkey = (settings: Settings): string => + getPublicKey(getRelayPrivateKey(settings.info?.relay_url)) + +/** getRelayNip43Pubkey, but undefined instead of a throw when SECRET is unset. */ +export const tryGetRelayNip43Pubkey = (settings: Settings): string | undefined => { + try { + return getRelayNip43Pubkey(settings) + } catch (error) { + logger('unable to derive the relay signing pubkey: %o', error) + return undefined + } +} + +/** + * undefined = nothing to compare against (`info.self` unset or not derivable), + * true/false = `info.self` is configured and (mis)matches the signing pubkey. + * Throws when `info.self` is a malformed npub. + */ +export const isRelaySelfConsistent = (settings: Settings): boolean | undefined => { + const configured = parseRelayPubkey(settings.info?.self) + if (configured === undefined) { + return undefined + } + + const derived = tryGetRelayNip43Pubkey(settings) + if (derived === undefined) { + return undefined + } + + return configured === derived +} + +/** + * The pubkey to advertise as `self` in NIP-11: the configured one when it parses, + * otherwise the derived signing pubkey, which is correct by construction. + */ +export const resolveRelaySelfPubkey = (settings: Settings): string | undefined => { + let configured: string | undefined + + try { + configured = parseRelayPubkey(settings.info?.self) + } catch (error) { + logger('info.self is not a valid pubkey, falling back to the derived one: %o', error) + } + + return configured ?? tryGetRelayNip43Pubkey(settings) +} + +// NIP-43 kind 28935 is not an event clients publish: it is a REQ the relay +// answers by minting an invite code on the fly and returning a relay-signed +// ephemeral event. This detects those REQ filters. +export const isNip43InviteRequestFilter = (filter: SubscriptionFilter): boolean => + Array.isArray(filter.kinds) && filter.kinds.includes(EventKinds.NIP43_INVITE_REQUEST) diff --git a/test/unit/cli/docs.spec.ts b/test/unit/cli/docs.spec.ts index 7d464d4c..94bc8dcd 100644 --- a/test/unit/cli/docs.spec.ts +++ b/test/unit/cli/docs.spec.ts @@ -30,6 +30,22 @@ describe('cli documentation alignment', () => { expect(readme).to.include('nostream invite create') }) + it('documents how to obtain the relay signing pubkey', () => { + const cliDoc = fs.readFileSync(path.join(projectRoot, 'CLI.md'), 'utf-8') + const configurationDoc = fs.readFileSync(path.join(projectRoot, 'CONFIGURATION.md'), 'utf-8') + + expect(cliDoc).to.include('relay.signingPubkey') + expect(cliDoc).to.include("Leave `info.self` unset") + expect(configurationDoc).to.include('nostream info') + }) + + it('does not claim kind 28935 is unimplemented', () => { + const cliDoc = fs.readFileSync(path.join(projectRoot, 'CLI.md'), 'utf-8') + + expect(cliDoc).to.not.include('does not yet generate kind 28935') + expect(cliDoc).to.include('answers `REQ`s for kind 28935') + }) + it('does not ship removed legacy wrapper scripts', () => { const removedWrappers = [ 'start', diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index e24c0294..d1cfba62 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -18,6 +18,7 @@ import { GiftWrapEventStrategy } from '../../../src/handlers/event-strategies/gi import { GroupEventStrategy } from '../../../src/handlers/event-strategies/group-event-strategy' import { IEventStrategy } from '../../../src/@types/message-handlers' import { ICacheAdapter, IWebSocketAdapter } from '../../../src/@types/adapters' +import { InviteRequestEventStrategy } from '../../../src/handlers/event-strategies/invite-request-event-strategy' import { JoinRequestEventStrategy } from '../../../src/handlers/event-strategies/join-request-event-strategy' import { LeaveRequestEventStrategy } from '../../../src/handlers/event-strategies/leave-request-event-strategy' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' @@ -152,6 +153,15 @@ describe('eventStrategyFactory', () => { expect(factory([event, adapter])).to.be.an.instanceOf(LeaveRequestEventStrategy) }) + // 28935 travels relay -> client only. It sits in the ephemeral range, so without + // an explicit branch it would fall through to EphemeralEventStrategy and be + // broadcast to everyone subscribed to kind 28935 — the clients awaiting an invite. + it('returns InviteRequestEventStrategy given a NIP-43 invite request (kind 28935)', () => { + event.kind = EventKinds.NIP43_INVITE_REQUEST + expect(factory([event, adapter])).to.be.an.instanceOf(InviteRequestEventStrategy) + expect(factory([event, adapter])).to.not.be.an.instanceOf(EphemeralEventStrategy) + }) + it('returns DvmJobRequestEventStrategy given a DVM job request (kind 5000-5999)', () => { event.kind = EventKinds.DVM_JOB_REQUEST_FIRST expect(factory([event, adapter])).to.be.an.instanceOf(DvmJobRequestEventStrategy) diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 71b48890..fa3b646b 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -1269,6 +1269,17 @@ describe('EventMessageHandler', () => { return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined }) + // Not because publishing 28935 is valid — it never is — but so the event + // reaches InviteRequestEventStrategy and the client is told to use a REQ. + // Non-members are exactly who gets this wrong, so they must not be the only + // ones who never see that message. + it('fulfills with undefined for an invite request from a non-member', async () => { + event.kind = EventKinds.NIP43_INVITE_REQUEST + userRepositoryFindByPubkeyStub.resolves(undefined) + + return expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + it('skips the minimum balance check when payments are disabled', async () => { settings.limits.event.pubkey.minBalance = 1000n userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, isVanished: false, balance: 0n }) diff --git a/test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts b/test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts new file mode 100644 index 00000000..908a9834 --- /dev/null +++ b/test/unit/handlers/event-strategies/invite-request-event-strategy.spec.ts @@ -0,0 +1,59 @@ +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { InviteRequestEventStrategy } from '../../../../src/handlers/event-strategies/invite-request-event-strategy' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +chai.use(sinonChai) +const { expect } = chai + +describe('InviteRequestEventStrategy', () => { + let adapter: IWebSocketAdapter + let strategy: InviteRequestEventStrategy + let emitStub: sinon.SinonStub + let event: Event + + beforeEach(() => { + emitStub = sinon.stub() + adapter = { emit: emitStub } as any + strategy = new InviteRequestEventStrategy(adapter) + + event = { + id: 'f'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: Math.floor(Date.now() / 1000), + kind: EventKinds.NIP43_INVITE_REQUEST, + tags: [['claim', 'forged-code'] as any], + content: '', + sig: 'b'.repeat(128), + } + }) + + afterEach(() => { + sinon.restore() + }) + + it('rejects a client-published invite request', async () => { + await strategy.execute(event) + + expect(emitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + 'OK', + event.id, + false, + 'invalid: kind 28935 is issued by the relay, request one with a REQ for kind 28935', + ]) + }) + + // Without this the event reaches EphemeralEventStrategy and is broadcast to + // every client subscribed to kind 28935 — precisely the clients waiting for a + // real invite. + it('never broadcasts the event', async () => { + await strategy.execute(event) + + expect(emitStub).to.not.have.been.calledWith(WebSocketAdapterEvent.Broadcast) + }) +}) 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 86d71999..fed61fa4 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -12,6 +12,8 @@ import { rootRequestHandler, } from '../../../../src/handlers/request-handlers/root-request-handler' import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base' +import * as eventUtils from '../../../../src/utils/event' +import { toBech32 } from '../../../../src/utils/transform' const baseSettings = { info: { @@ -145,7 +147,6 @@ describe('rootRequestHandler', () => { ...baseSettings.info, banner: 'https://relay.example.com/banner.png', icon: 'https://relay.example.com/icon.png', - self: 'f'.repeat(64), terms_of_service: 'https://relay.example.com/terms', }, }) @@ -155,7 +156,6 @@ describe('rootRequestHandler', () => { const doc = res.send.firstCall.args[0] expect(doc.banner).to.equal('https://relay.example.com/banner.png') expect(doc.icon).to.equal('https://relay.example.com/icon.png') - expect(doc.self).to.equal('f'.repeat(64)) expect(doc.terms_of_service).to.equal('https://relay.example.com/terms') }) @@ -165,10 +165,68 @@ describe('rootRequestHandler', () => { const doc = res.send.firstCall.args[0] expect(doc).to.not.have.property('banner') expect(doc).to.not.have.property('icon') - expect(doc).to.not.have.property('self') expect(doc).to.not.have.property('terms_of_service') }) + // NIP-43 clients verify relay-signed events against `self`, so the document + // must advertise the key the relay actually signs with, not a stale or + // placeholder string. + describe('self pubkey', () => { + const derivedPubkey = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const configuredPubkey = '1e0d0c0b0a09080706050403020100ff0e0d0c0b0a09080706050403020100ff' + + let getRelayPrivateKeyStub: sinon.SinonStub + let getPublicKeyStub: sinon.SinonStub + + const sendWithSelf = (self?: string) => { + createSettingsStub.returns({ + ...baseSettings, + info: { ...baseSettings.info, ...(self !== undefined ? { self } : {}) }, + }) + + rootRequestHandler(req, res, next) + + return res.send.firstCall.args[0] + } + + beforeEach(() => { + getRelayPrivateKeyStub = sinon.stub(eventUtils, 'getRelayPrivateKey').returns('deadbeef') + getPublicKeyStub = sinon.stub(eventUtils, 'getPublicKey').returns(derivedPubkey) + }) + + afterEach(() => { + getRelayPrivateKeyStub.restore() + getPublicKeyStub.restore() + }) + + it('advertises the derived signing pubkey when info.self is unset', () => { + expect(sendWithSelf().self).to.equal(derivedPubkey) + }) + + it('advertises the derived signing pubkey when info.self is the placeholder default', () => { + expect(sendWithSelf('replace-with-your-relay-pubkey-in-hex').self).to.equal(derivedPubkey) + }) + + it('advertises a configured hex info.self verbatim', () => { + expect(sendWithSelf(configuredPubkey).self).to.equal(configuredPubkey) + }) + + it('decodes a configured npub info.self', () => { + expect(sendWithSelf(toBech32('npub')(configuredPubkey)).self).to.equal(configuredPubkey) + }) + + it('does not throw on a malformed npub info.self', () => { + expect(sendWithSelf('npub1notavalidbech32string').self).to.equal(derivedPubkey) + expect(next).to.not.have.been.called + }) + + it('omits self when it cannot be derived and none is configured', () => { + getRelayPrivateKeyStub.throws(new Error('SECRET environment variable not set')) + + expect(sendWithSelf()).to.not.have.property('self') + }) + }) + it('includes NIP-11 limitation created_at and default_limit fields', () => { createSettingsStub.returns({ ...baseSettings, diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 6c414880..8bfe2d27 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -8,8 +8,11 @@ import sinonChai from 'sinon-chai' import { IAbortable, IMessageHandler } from '../../../src/@types/message-handlers' import { MessageType, SubscribeMessage } from '../../../src/@types/messages' import { SubscriptionFilter, SubscriptionId } from '../../../src/@types/subscription' +import * as eventUtils from '../../../src/utils/event' import { Event } from '../../../src/@types/event' -import { IEventRepository } from '../../../src/@types/repositories' +import { EventTags } from '../../../src/constants/base' +import { IEventRepository, IInviteCodeRepository } from '../../../src/@types/repositories' +import { IRateLimiter } from '../../../src/@types/utils' import { IWebSocketAdapter } from '../../../src/@types/adapters' import { PassThrough } from 'stream' import { SubscribeMessageHandler } from '../../../src/handlers/subscribe-message-handler' @@ -42,6 +45,11 @@ describe('SubscribeMessageHandler', () => { let settingsFactory: Sinon.SinonStub let webSocketGetSubscriptionsStub: Sinon.SinonStub let eventRepositoryFindByFiltersStub: Sinon.SinonSpy + let inviteCodeRepository: IInviteCodeRepository + let inviteCodeRepositoryCreateStub: Sinon.SinonStub + let rateLimiter: IRateLimiter + let rateLimiterHitStub: Sinon.SinonStub + let rateLimiterFactory: Sinon.SinonStub let sandbox: Sinon.SinonSandbox @@ -61,8 +69,28 @@ describe('SubscribeMessageHandler', () => { }) eventRepository = { findByFilters: eventRepositoryFindByFiltersStub, + create: sandbox.stub(), } as any - handler = new SubscribeMessageHandler(webSocket, eventRepository, settingsFactory) + inviteCodeRepositoryCreateStub = sandbox.stub().callsFake(async (code: string, options: any) => ({ + code, + createdBy: options?.createdBy ?? null, + claimedBy: null, + expiresAt: options?.expiresAt ?? null, + remainingUses: options?.remainingUses ?? 1, + createdAt: new Date(), + updatedAt: new Date(), + })) + inviteCodeRepository = { create: inviteCodeRepositoryCreateStub } as any + rateLimiterHitStub = sandbox.stub().resolves(false) + rateLimiter = { hit: rateLimiterHitStub } as any + rateLimiterFactory = sandbox.stub().returns(rateLimiter) + handler = new SubscribeMessageHandler( + webSocket, + eventRepository, + settingsFactory, + inviteCodeRepository, + rateLimiterFactory, + ) }) afterEach(() => { @@ -147,6 +175,330 @@ describe('SubscribeMessageHandler', () => { }) }) + // NIP-43: kind 28935 is answered by minting a code and returning a relay-signed + // ephemeral event on the requesting socket. These tests exercise handleMessage + // end to end (fetchAndSend is NOT stubbed) so the EOSE assertions are real. + describe('#handleMessage NIP-43 invite requests', () => { + const relayPrivkey = '5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a' + const requester = 'a'.repeat(64) + const stranger = 'b'.repeat(64) + + let relayPubkey: string + let webSocketOnMessageStub: Sinon.SinonStub + let webSocketOnBroadcastStub: Sinon.SinonStub + let getRelayPrivateKeyStub: Sinon.SinonStub + + const nip43Settings = (overrides: Record = {}) => ({ + enabled: true, + allowInviteRequests: true, + inviteCodeExpirySeconds: 600, + defaultMaxUses: 1, + ...overrides, + }) + + const settingsWith = (overrides: Record = {}) => ({ + info: { relay_url: 'wss://relay.example.com' }, + limits: { client: { subscription: {} } }, + nip43: nip43Settings(), + ...overrides, + }) + + // Drives a full REQ and lets fetchAndSend stream to completion so a genuine + // EOSE is emitted. + const request = async (filters: SubscriptionFilter[], dbEvents: any[] = []) => { + const promise = handler.handleMessage([MessageType.REQ, subscriptionId, ...filters] as any) + for (const dbEvent of dbEvents) { + stream.write(dbEvent) + } + stream.end() + await promise + } + + const emittedMessages = () => webSocketOnMessageStub.getCalls().map((call) => call.args[0]) + const emittedEvents = (): Event[] => + emittedMessages() + .filter((message) => message[0] === 'EVENT') + .map((message) => message[2]) + const claimEvents = () => emittedEvents().filter((event) => event.kind === 28935) + const sentEOSE = () => emittedMessages().some((message) => message[0] === 'EOSE') + + beforeEach(() => { + // Stub only the derivation: real signing still runs, so the emitted event's + // signature is genuinely verifiable against the derived pubkey. + getRelayPrivateKeyStub = sandbox.stub(eventUtils, 'getRelayPrivateKey').returns(relayPrivkey) + relayPubkey = eventUtils.getPublicKey(relayPrivkey) + + settingsFactory.returns(settingsWith()) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set([requester])) + + webSocketOnMessageStub = sandbox.stub() + webSocketOnBroadcastStub = sandbox.stub() + webSocket.on(WebSocketAdapterEvent.Message, webSocketOnMessageStub) + webSocket.on(WebSocketAdapterEvent.Broadcast, webSocketOnBroadcastStub) + }) + + describe('when every guard passes', () => { + it('emits exactly one claim event followed by EOSE', async () => { + await request([{ kinds: [28935] }]) + + const messages = emittedMessages() + expect(messages).to.have.lengthOf(2) + expect(messages[0][0]).to.equal('EVENT') + expect(messages[0][1]).to.equal(subscriptionId) + expect(messages[1]).to.deep.equal(['EOSE', subscriptionId]) + }) + + it('emits a kind 28935 event carrying the minted claim code', async () => { + await request([{ kinds: [28935] }]) + + const [event] = claimEvents() + const claim = event.tags.find((tag) => tag[0] === EventTags.Claim) + + expect(event.kind).to.equal(28935) + expect(event.content).to.equal('') + expect(claim?.[1]).to.equal(inviteCodeRepositoryCreateStub.firstCall.args[0]) + }) + + it('signs the event as the derived relay pubkey with a valid id and signature', async () => { + await request([{ kinds: [28935] }]) + + const [event] = claimEvents() + + expect(event.pubkey).to.equal(relayPubkey) + expect(getRelayPrivateKeyStub).to.have.been.calledWith('wss://relay.example.com') + expect(await eventUtils.isEventIdValid(event)).to.equal(true) + expect(await eventUtils.isEventSignatureValid(event)).to.equal(true) + }) + + it('tags the event as NIP-70 protected', async () => { + await request([{ kinds: [28935] }]) + + expect(claimEvents()[0].tags).to.deep.include([EventTags.Protected]) + }) + + it('adds a NIP-40 expiration tag mirroring inviteCodeExpirySeconds', async () => { + await request([{ kinds: [28935] }]) + + const [event] = claimEvents() + const expiration = event.tags.find((tag) => tag[0] === EventTags.Expiration) + const expiresAt: Date = inviteCodeRepositoryCreateStub.firstCall.args[1].expiresAt + + expect(expiration?.[1]).to.equal(String(Math.floor(expiresAt.getTime() / 1000))) + }) + + it('omits the expiration tag when codes never expire', async () => { + settingsFactory.returns(settingsWith({ nip43: nip43Settings({ inviteCodeExpirySeconds: 0 }) })) + + await request([{ kinds: [28935] }]) + + expect(claimEvents()[0].tags.some((tag) => tag[0] === EventTags.Expiration)).to.equal(false) + }) + + it('records the requesting pubkey as createdBy', async () => { + await request([{ kinds: [28935] }]) + + expect(inviteCodeRepositoryCreateStub).to.have.been.calledOnce + expect(inviteCodeRepositoryCreateStub.firstCall.args[1]).to.include({ createdBy: requester, remainingUses: 1 }) + }) + + it('never persists the claim event', async () => { + await request([{ kinds: [28935] }]) + + expect(eventRepository.create).to.not.have.been.called + }) + + it('never broadcasts the claim event', async () => { + await request([{ kinds: [28935] }]) + + expect(webSocketOnBroadcastStub).to.not.have.been.called + }) + + it('mints for a pubkey on a non-empty inviteRequestWhitelist', async () => { + settingsFactory.returns( + settingsWith({ nip43: nip43Settings({ inviteRequestWhitelist: [stranger, requester] }) }), + ) + + await request([{ kinds: [28935] }]) + + expect(claimEvents()).to.have.lengthOf(1) + }) + + it('mints when info.self matches the derived signing pubkey', async () => { + settingsFactory.returns(settingsWith({ info: { relay_url: 'wss://relay.example.com', self: relayPubkey } })) + + await request([{ kinds: [28935] }]) + + expect(claimEvents()).to.have.lengthOf(1) + }) + }) + + // Every guard fails the same way: no claim event, no error to the client, and + // EOSE still sent. A REQ has no OK channel to report a reason on. + describe('guards', () => { + const expectSkipped = () => { + expect(claimEvents()).to.have.lengthOf(0) + expect(inviteCodeRepositoryCreateStub).to.not.have.been.called + expect(sentEOSE()).to.equal(true) + } + + it('skips when info.self does not match the derived signing pubkey', async () => { + settingsFactory.returns(settingsWith({ info: { relay_url: 'wss://relay.example.com', self: 'f'.repeat(64) } })) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when info.self is a malformed npub', async () => { + settingsFactory.returns( + settingsWith({ info: { relay_url: 'wss://relay.example.com', self: 'npub1notavalidbech32string' } }), + ) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when NIP-43 is disabled', async () => { + settingsFactory.returns(settingsWith({ nip43: nip43Settings({ enabled: false }) })) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when allowInviteRequests is not enabled', async () => { + settingsFactory.returns(settingsWith({ nip43: nip43Settings({ allowInviteRequests: false }) })) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when the nip43 block is absent entirely', async () => { + settingsFactory.returns(settingsWith({ nip43: undefined })) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when the requester is not NIP-42 authenticated', async () => { + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set()) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when a non-empty inviteRequestWhitelist does not include the requester', async () => { + settingsFactory.returns(settingsWith({ nip43: nip43Settings({ inviteRequestWhitelist: [stranger] }) })) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('skips when the per-pubkey rate limit is exceeded', async () => { + settingsFactory.returns( + settingsWith({ + limits: { client: { subscription: {} }, invite: { rateLimits: [{ period: 3600000, rate: 5 }] } }, + }), + ) + rateLimiterHitStub.resolves(true) + + await request([{ kinds: [28935] }]) + + expect(rateLimiterHitStub).to.have.been.calledOnceWithExactly(`${requester}:invites:3600000`, 1, { + period: 3600000, + rate: 5, + }) + expectSkipped() + }) + + it('fails closed when the rate limiter is unavailable', async () => { + settingsFactory.returns( + settingsWith({ + limits: { client: { subscription: {} }, invite: { rateLimits: [{ period: 3600000, rate: 5 }] } }, + }), + ) + rateLimiterHitStub.rejects(new Error('redis is down')) + + await request([{ kinds: [28935] }]) + + expectSkipped() + }) + + it('does not consult the rate limiter when no invite rate limits are configured', async () => { + await request([{ kinds: [28935] }]) + + expect(rateLimiterHitStub).to.not.have.been.called + expect(claimEvents()).to.have.lengthOf(1) + }) + }) + + describe('boundaries', () => { + it('does not mint for a REQ that does not ask for kind 28935', async () => { + await request([{ kinds: [1] }]) + + expect(inviteCodeRepositoryCreateStub).to.not.have.been.called + expect(claimEvents()).to.have.lengthOf(0) + expect(sentEOSE()).to.equal(true) + }) + + it('does not mint for a filter with no kinds at all', async () => { + await request([{ authors: [requester] }]) + + expect(inviteCodeRepositoryCreateStub).to.not.have.been.called + }) + + // uniqWith(equals) upstream only collapses byte-identical filters, so this + // pair survives deduplication and would mint twice without an explicit guard. + it('mints exactly one code for two non-identical filters naming 28935', async () => { + await request([{ kinds: [28935] }, { kinds: [28935], limit: 1 }]) + + expect(inviteCodeRepositoryCreateStub).to.have.been.calledOnce + expect(claimEvents()).to.have.lengthOf(1) + }) + + it('serves stored events alongside the claim event on a mixed filter', async () => { + const storedEvent: Event = { + id: 'b1601d26958e6508b7b9df0af609c652346c09392b6534d93aead9819a51b4ef', + pubkey: '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793', + created_at: 1648339664, + kind: 1, + tags: [], + content: 'learning terraform rn!', + sig: 'ec8b2bc640c8c7e92fbc0e0a6f539da2635068a99809186f15106174d727456132977c78f3371d0ab01c108173df75750f33d8e04c4d7980bbb3fb70ba1e3848', + } + + await request([{ kinds: [1, 28935] }], [toDbEvent(storedEvent)]) + + expect(claimEvents()).to.have.lengthOf(1) + expect(emittedEvents().filter((event) => event.kind === 1)).to.deep.equal([storedEvent]) + expect(sentEOSE()).to.equal(true) + }) + + it('still sends EOSE when the invite code repository throws', async () => { + inviteCodeRepositoryCreateStub.rejects(new Error('unique violation on invite_codes_pkey')) + + await request([{ kinds: [28935] }]) + + expect(claimEvents()).to.have.lengthOf(0) + expect(sentEOSE()).to.equal(true) + }) + + it('still sends EOSE when the relay private key cannot be derived', async () => { + getRelayPrivateKeyStub.throws(new Error('SECRET environment variable not set')) + + await request([{ kinds: [28935] }]) + + expect(claimEvents()).to.have.lengthOf(0) + expect(sentEOSE()).to.equal(true) + }) + }) + }) + describe('#fetchAndSend', () => { let event: Event let clock: Sinon.SinonFakeTimers diff --git a/test/unit/utils/nip43-invites.spec.ts b/test/unit/utils/nip43-invites.spec.ts index 598d8688..f70c94fa 100644 --- a/test/unit/utils/nip43-invites.spec.ts +++ b/test/unit/utils/nip43-invites.spec.ts @@ -6,6 +6,7 @@ import { InviteCode } from '../../../src/@types/invite-code' import { IInviteCodeRepository } from '../../../src/@types/repositories' import { Nip43Settings } from '../../../src/@types/settings' import { + buildInviteCodeEvent, DEFAULT_INVITE_CODE_EXPIRY_SECONDS, DEFAULT_INVITE_MAX_USES, generateInviteCode, @@ -14,6 +15,8 @@ import { parseRelayPubkey, resolveInviteCodeLimits, } from '../../../src/utils/nip43-invites' +import { EventTags } from '../../../src/constants/base' +import { getPublicKey, isEventIdValid, isEventSignatureValid } from '../../../src/utils/event' import { toBech32 } from '../../../src/utils/transform' chai.use(sinonChai) @@ -181,4 +184,61 @@ describe('nip43-invites', () => { expect((repository.create as sinon.SinonStub).called).to.equal(false) }) }) + + describe('buildInviteCodeEvent', () => { + const relayPrivkey = '5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a' + const relayPubkey = getPublicKey(relayPrivkey) + const code = 'ffee0011223344556677889900aabbcc' + + it('builds a kind 28935 event signed by the relay', async () => { + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt: null }, 1700000000) + + expect(event.kind).to.equal(28935) + expect(event.pubkey).to.equal(relayPubkey) + expect(event.created_at).to.equal(1700000000) + expect(event.content).to.equal('') + }) + + it('produces a valid event id and signature', async () => { + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt: null }) + + expect(await isEventIdValid(event)).to.equal(true) + expect(await isEventSignatureValid(event)).to.equal(true) + }) + + it('carries the claim code in a claim tag', async () => { + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt: null }) + + expect(event.tags).to.deep.include([EventTags.Claim, code]) + }) + + it('marks the event NIP-70 protected', async () => { + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt: null }) + + expect(event.tags).to.deep.include([EventTags.Protected]) + }) + + it('adds a NIP-40 expiration tag in seconds when the code expires', async () => { + const expiresAt = new Date('2026-08-20T12:00:00.000Z') + + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt }) + + expect(event.tags).to.deep.include([EventTags.Expiration, String(expiresAt.getTime() / 1000)]) + }) + + it('omits the expiration tag when the code never expires', async () => { + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt: null }) + + expect(event.tags.some((tag) => tag[0] === EventTags.Expiration)).to.equal(false) + }) + + it('defaults created_at to now', async () => { + const before = Math.floor(Date.now() / 1000) + + const event = await buildInviteCodeEvent(relayPrivkey, relayPubkey, { code, expiresAt: null }) + + expect(event.created_at).to.be.at.least(before) + expect(event.created_at).to.be.at.most(Math.floor(Date.now() / 1000)) + }) + }) }) diff --git a/test/unit/utils/nip43.spec.ts b/test/unit/utils/nip43.spec.ts new file mode 100644 index 00000000..7d6d875e --- /dev/null +++ b/test/unit/utils/nip43.spec.ts @@ -0,0 +1,152 @@ +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import * as eventUtils from '../../../src/utils/event' +import { + getRelayNip43Pubkey, + isNip43InviteRequestFilter, + isRelaySelfConsistent, + resolveRelaySelfPubkey, + tryGetRelayNip43Pubkey, +} from '../../../src/utils/nip43' +import { Settings } from '../../../src/@types/settings' +import { toBech32 } from '../../../src/utils/transform' + +chai.use(sinonChai) +const { expect } = chai + +describe('nip43 relay self pubkey', () => { + const derivedPubkey = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const otherPubkey = '1e0d0c0b0a09080706050403020100ff0e0d0c0b0a09080706050403020100ff' + + let sandbox: sinon.SinonSandbox + let getRelayPrivateKeyStub: sinon.SinonStub + let getPublicKeyStub: sinon.SinonStub + + const settingsWith = (self?: string): Settings => + ({ info: { relay_url: 'wss://relay.example.com', ...(self !== undefined ? { self } : {}) } }) as Settings + + beforeEach(() => { + sandbox = sinon.createSandbox() + // getRelayPrivateKey memoises in a module-level cache that ignores its + // argument, so stub it rather than relying on a fresh derive per call. + getRelayPrivateKeyStub = sandbox.stub(eventUtils, 'getRelayPrivateKey').returns('deadbeef') + getPublicKeyStub = sandbox.stub(eventUtils, 'getPublicKey').returns(derivedPubkey) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('getRelayNip43Pubkey', () => { + it('derives the pubkey from the relay_url purpose', () => { + expect(getRelayNip43Pubkey(settingsWith())).to.equal(derivedPubkey) + expect(getRelayPrivateKeyStub).to.have.been.calledOnceWithExactly('wss://relay.example.com') + expect(getPublicKeyStub).to.have.been.calledOnceWithExactly('deadbeef') + }) + + it('returns 64 lowercase hex characters', () => { + getPublicKeyStub.callsFake((privkey: string) => getPublicKeyStub.wrappedMethod(privkey)) + getRelayPrivateKeyStub.returns(`${'0'.repeat(63)}1`) + + expect(getRelayNip43Pubkey(settingsWith())).to.match(/^[0-9a-f]{64}$/) + }) + + it('throws when SECRET is unset', () => { + getRelayPrivateKeyStub.throws(new Error('SECRET environment variable not set')) + + expect(() => getRelayNip43Pubkey(settingsWith())).to.throw('SECRET environment variable not set') + }) + }) + + describe('tryGetRelayNip43Pubkey', () => { + it('returns undefined instead of throwing when SECRET is unset', () => { + getRelayPrivateKeyStub.throws(new Error('SECRET environment variable not set')) + + expect(tryGetRelayNip43Pubkey(settingsWith())).to.be.undefined + }) + }) + + describe('isRelaySelfConsistent', () => { + it('returns undefined when info.self is unset', () => { + expect(isRelaySelfConsistent(settingsWith())).to.be.undefined + }) + + it('returns undefined when info.self is the placeholder default', () => { + expect(isRelaySelfConsistent(settingsWith('replace-with-your-relay-pubkey-in-hex'))).to.be.undefined + }) + + it('returns undefined when the signing pubkey cannot be derived', () => { + getRelayPrivateKeyStub.throws(new Error('SECRET environment variable not set')) + + expect(isRelaySelfConsistent(settingsWith(derivedPubkey))).to.be.undefined + }) + + it('returns true when info.self matches in hex', () => { + expect(isRelaySelfConsistent(settingsWith(derivedPubkey.toUpperCase()))).to.equal(true) + }) + + it('returns true when info.self matches as an npub', () => { + expect(isRelaySelfConsistent(settingsWith(toBech32('npub')(derivedPubkey)))).to.equal(true) + }) + + it('returns false when info.self does not match', () => { + expect(isRelaySelfConsistent(settingsWith(otherPubkey))).to.equal(false) + }) + + it('throws when info.self is a malformed npub', () => { + expect(() => isRelaySelfConsistent(settingsWith('npub1notavalidbech32string'))).to.throw() + }) + }) + + describe('resolveRelaySelfPubkey', () => { + it('falls back to the derived pubkey when info.self is unset', () => { + expect(resolveRelaySelfPubkey(settingsWith())).to.equal(derivedPubkey) + }) + + it('falls back to the derived pubkey when info.self is the placeholder default', () => { + expect(resolveRelaySelfPubkey(settingsWith('replace-with-your-relay-pubkey-in-hex'))).to.equal(derivedPubkey) + }) + + it('prefers a configured hex info.self, normalised to lowercase', () => { + expect(resolveRelaySelfPubkey(settingsWith(otherPubkey.toUpperCase()))).to.equal(otherPubkey) + }) + + it('decodes a configured npub info.self', () => { + expect(resolveRelaySelfPubkey(settingsWith(toBech32('npub')(otherPubkey)))).to.equal(otherPubkey) + }) + + it('falls back to the derived pubkey instead of throwing on a malformed npub', () => { + expect(resolveRelaySelfPubkey(settingsWith('npub1notavalidbech32string'))).to.equal(derivedPubkey) + }) + + it('returns undefined when nothing is configured and SECRET is unset', () => { + getRelayPrivateKeyStub.throws(new Error('SECRET environment variable not set')) + + expect(resolveRelaySelfPubkey(settingsWith())).to.be.undefined + }) + }) + + describe('isNip43InviteRequestFilter', () => { + it('matches a filter naming kind 28935', () => { + expect(isNip43InviteRequestFilter({ kinds: [28935] })).to.equal(true) + }) + + it('matches a filter naming 28935 alongside other kinds', () => { + expect(isNip43InviteRequestFilter({ kinds: [1, 28935] })).to.equal(true) + }) + + it('does not match a filter naming other kinds', () => { + expect(isNip43InviteRequestFilter({ kinds: [28934, 28936] })).to.equal(false) + }) + + it('does not match a filter with no kinds', () => { + expect(isNip43InviteRequestFilter({ authors: ['a'.repeat(64)] })).to.equal(false) + }) + + it('does not match an empty filter', () => { + expect(isNip43InviteRequestFilter({})).to.equal(false) + }) + }) +}) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index f0856024..91598c41 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -268,6 +268,22 @@ describe('SettingsStatic', () => { expect(defaults).to.have.nested.property('nip43.enabled', false) expect(defaults).to.have.nested.property('nip43.inviteCodeExpirySeconds', 600) expect(defaults).to.have.nested.property('nip43.defaultMaxUses', 1) + expect(defaults).to.have.nested.property('nip43.allowInviteRequests', false) + expect(defaults).to.have.nested.property('nip43.inviteRequestWhitelist').that.deep.equals([]) + }) + + it('default-settings.yaml rate limits kind 28935 invite requests per pubkey', () => { + const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath()) + + expect(defaults).to.have.nested.property('limits.invite.rateLimits').that.is.an('array').with.lengthOf(1) + expect(defaults).to.have.nested.property('limits.invite.rateLimits.0.period', 3_600_000) + expect(defaults).to.have.nested.property('limits.invite.rateLimits.0.rate', 5) + }) + + it('default-settings.yaml leaves info.self unset so it is derived', () => { + const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath()) as Settings + + expect(defaults.info).to.not.have.property('self') }) it('user config nip43 block overrides defaults', () => {