Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/nip43-invite-requests.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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. |
Expand All @@ -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('<your_language>', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. |
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 19 additions & 1 deletion resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ export interface AdmissionCheckLimits {
ipWhitelist?: string[]
}

export interface InviteLimits {
rateLimits?: RateLimit[]
}

export interface AdminLimits {
rateLimits?: RateLimit[]
loginRateLimits?: RateLimit[]
Expand All @@ -150,6 +154,7 @@ export interface AdminLimits {
export interface Limits {
rateLimiter?: RateLimiterSettings
invoice?: InvoiceLimits
invite?: InviteLimits
admissionCheck?: AdmissionCheckLimits
admin?: AdminLimits
connection?: ConnectionLimits
Expand Down
5 changes: 5 additions & 0 deletions src/cli/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -236,6 +240,7 @@ export const runInfo = async (options: InfoOptions): Promise<number> => {
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}`)
Expand Down
10 changes: 7 additions & 3 deletions src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 11 additions & 2 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions src/handlers/event-strategies/invite-request-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -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<Event, Promise<void>> {
public constructor(private readonly webSocket: IWebSocketAdapter) {}

public async execute(event: Event): Promise<void> {
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',
),
)
}
}
9 changes: 7 additions & 2 deletions src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Loading
Loading