Files
smirk-backend-core/docs/operations/RELAY.md
T
jwinterm 635cb64601 fix(relay): harden admission (fail-safe, loopback-only, bounded, size cap) [review]
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).
2026-07-02 08:35:37 -04:00

110 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `/capabilities` and 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
1. Set the relay env (see `.env.example` → *Nostr relay*):
```sh
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 admission
```
The backend fail-closes at startup on a bad relay config, and for any non-`open`
policy it spawns the gRPC admission service on `RELAY_ADMISSION_BIND`.
2. **Bundled** (`RELAY_MODE=bundled`): run the reference `nostr-rs-relay` in
[`deploy/relay/`](../../deploy/relay/):
```sh
# set relay_url + event_admission_server in deploy/relay/config.toml first
docker compose -f deploy/relay/docker-compose.yml up -d
```
It 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_URL` with your TLS terminator.
3. **External** (`RELAY_MODE=external`): point `RELAY_URL` at any relay you run,
and configure *its* `[grpc] event_admission_server` at `RELAY_ADMISSION_BIND`.
## Verify
- `GET /capabilities` → `features.nostr_relay: true` and a `messaging` object
(`relay_url`, `write_policy`, `inbound_pow_bits`, `supported_nips`).
- `GET /.well-known/nostr.json?name=<user>` lists your relay first in `relays`.
- Publish a registered-npub event → accepted; a random-author, non-gift-wrap
event → rejected (`inbox-outbox`); an under-PoW cross-ecosystem event →
rejected (when `RELAY_INBOUND_POW_BITS > 0`).
## Security notes (read before exposing a public relay)
- **The relay fails OPEN if admission is unreachable.** `nostr-rs-relay` treats 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-loopback
`RELAY_ADMISSION_BIND` unless you set `RELAY_ADMISSION_ALLOW_PUBLIC=true` and
firewall it yourself.
- **Default `RELAY_INBOUND_POW_BITS=0` has no spam friction.** Under `inbox-outbox`
a `p` tag 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.