Adversarial review of the relay admission surface: - FAIL-SAFE on admission death: nostr-rs-relay fails OPEN if its gRPC admission server is unreachable, so a dead admission = an unrestricted relay. The backend now EXITS if the admission service stops (supervisor restarts a clean pair); documented in RELAY.md. Pin the relay image to a digest (compose note). - Registration-oracle protection: validate() now refuses a non-loopback RELAY_ADMISSION_BIND unless RELAY_ADMISSION_ALLOW_PUBLIC=true (+ test). - No N+1 on crafted events: recipient p-tags are capped (20, deduped) and resolved in ONE batched query (db.any_registered_npub) instead of one-per-tag. - Defence-in-depth size cap: admission denies events over max_event_bytes before any DB work (the relay enforces its own limit too). - Docs: RELAY.md Security section (fail-open, loopback oracle, default PoW=0 spam footgun, client sender-verification); config.toml relay port 8088 (not 8080); Docker Hub image; .env.example RELAY_ADMISSION_ALLOW_PUBLIC. Note: the client-side sender-impersonation blocker is fixed separately in the monorepo (verifying gift-wrap unwrap).
5.6 KiB
Nostr relay (messaging plane) — operator guide
The relay is optional. It gives your users a first-party, locked-down Nostr relay that acts as their encrypted-DM inbox — the transport foundation for messaging (and, later, P2P coordination). It is off by default; a Smirk backend is fully functional without it.
What it is (and isn't)
- It is the user's inbox relay, advertised via
/capabilitiesand NIP-05 relay hints, and write-restricted by policy (see below). - It is NOT the only relay. For cross-wallet delivery (e.g. to/from Goblin or
any Nostr user), clients also connect to the public interop relays
(
relay.damus.io,nos.lol). A private relay alone would black-hole DMs to outsiders — so the NIP-05 hints list your relay first, then the public set. - The backend does not run the relay process — it connects to + advertises it and enforces the write policy. You run the relay (bundled or external).
Architecture
wallet ──ws──▶ your relay (nostr-rs-relay) ──gRPC EventAdmit──▶ smirk-backend
(RELAY_WRITE_POLICY
+ NIP-13 PoW,
registered-npub DB)
nostr-rs-relay's [grpc] event_admission_server calls the backend once per
inbound event; the backend runs the pure policy engine (src/infra/relay/policy.rs)
against the user DB and returns PERMIT/DENY. This is dynamic (per-event, per-
recipient), which a static pubkey whitelist can't do.
Write policies (RELAY_WRITE_POLICY)
| policy | who may publish |
|---|---|
inbox-outbox (default) |
registered npubs publish their own events (outbox); anyone may deliver a NIP-17 gift-wrap (kind 1059) addressed to a registered user (inbox) |
author-allowlist |
only registered Smirk npubs may publish anything (no external inbound) |
open |
accept everything (the relay's resource caps still apply) |
RELAY_INBOUND_POW_BITS (0 = off) requires a NIP-13 proof-of-work on
cross-ecosystem (non-registered-author) inbound events — spam friction without an
author allowlist. Only the inbox-outbox external-inbound branch is gated.
Enable it
-
Set the relay env (see
.env.example→ Nostr relay):RELAY_ENABLED=true RELAY_MODE=bundled # or external RELAY_URL=wss://relay.yourdomain.tld # what clients connect to (wss in prod) RELAY_WRITE_POLICY=inbox-outbox RELAY_INBOUND_POW_BITS=0 RELAY_ADMISSION_BIND=127.0.0.1:8090 # loopback gRPC admissionThe backend fail-closes at startup on a bad relay config, and for any non-
openpolicy it spawns the gRPC admission service onRELAY_ADMISSION_BIND. -
Bundled (
RELAY_MODE=bundled): run the referencenostr-rs-relayindeploy/relay/:# set relay_url + event_admission_server in deploy/relay/config.toml first docker compose -f deploy/relay/docker-compose.yml up -dIt uses host networking so the relay reaches the loopback admission socket while that socket stays private (Linux; see the compose comments for other hosts). Front
RELAY_URLwith your TLS terminator. -
External (
RELAY_MODE=external): pointRELAY_URLat any relay you run, and configure its[grpc] event_admission_serveratRELAY_ADMISSION_BIND.
Verify
GET /capabilities→features.nostr_relay: trueand amessagingobject (relay_url,write_policy,inbound_pow_bits,supported_nips).GET /.well-known/nostr.json?name=<user>lists your relay first inrelays.- Publish a registered-npub event → accepted; a random-author, non-gift-wrap
event → rejected (
inbox-outbox); an under-PoW cross-ecosystem event → rejected (whenRELAY_INBOUND_POW_BITS > 0).
Security notes (read before exposing a public relay)
- The relay fails OPEN if admission is unreachable.
nostr-rs-relaytreats an unreachable gRPC admission server as PERMIT. So a down/crashed admission service = an unrestricted relay. Two defences: (1) the backend exits if its admission service stops (so your supervisor restarts a clean pair — don't run them independently), and (2) run the relay + backend as one supervised unit so the relay isn't serving while admission is down. Pin the relay image to a digest, not:latest— the fail-open semantics can change across versions. - The admission socket is a registration oracle (it answers "is this npub
registered?"). It MUST stay loopback;
validate()refuses a non-loopbackRELAY_ADMISSION_BINDunless you setRELAY_ADMISSION_ALLOW_PUBLIC=trueand firewall it yourself. - Default
RELAY_INBOUND_POW_BITS=0has no spam friction. Underinbox-outboxaptag is public, so anyone can address gift-wraps to a registered user's inbox. A PUBLIC operator should raise the PoW bits (e.g. 8–16) to blunt inbox flooding; content is always encrypted, so this is a storage/spam concern, not a confidentiality one. - Clients verify the sender. A gift-wrap's inner author is only trusted after
the wallet checks the seal signature +
seal.pubkey == rumor.pubkey, so a relay/sender cannot forge the displayed "from".
Modularity
nostr-rs-relay is the first adapter behind the RelayProvider seam
(src/infra/relay/). The write policy lives in a pure engine; the gRPC transport
is the adapter's concern. A different relay (strfry, or another messaging
protocol) can enforce the same policy over a different mechanism without touching
the policy or the advertising.