Commit Graph

5 Commits

Author SHA1 Message Date
Claude b7ba3f6d15 fix(relay): security + concurrency audit + on-disk persistence
Addresses every Critical/High/Medium finding from the code-quality
audit, plus the operator request to persist NIP-86 admin state and
NIP-11 doc mutations across restarts.

## Security (audit Critical)

- C1 — NIP-98 verifier no longer trusts the client-supplied `Host`
  header. New `LocalRelayServer.publicUrl` (and `[admin].public_url`)
  must be set in any production deployment; the verifier compares
  against this fixed string. Falls back to Host for loopback unit
  tests with a docstring warning.

- C2 — NIP-98 replay protection. Verifier now keeps a bounded
  LinkedHashMap of recently-accepted event ids (TTL = 2× tolerance,
  max 1024 entries, LRU evicted) and rejects duplicates. Replay check
  runs LAST so a one-shot id isn't burned on a request that fails
  signature/url/method validation.

- C3 — NIP-86 admin POST body is now capped at 1 MiB (configurable
  via `LocalRelayServer.maxAdminBodyBytes`). The `Content-Length`
  header is honored as a fast pre-read reject; any body that exceeds
  the cap during streaming returns 413 PayloadTooLarge before being
  buffered.

## Concurrency / correctness (audit High)

- H1 — Per-session writer coroutine + bounded outbound queue
  (SESSION_OUTGOING_BUFFER = 1024). When the queue fills, the
  connection is closed cleanly instead of silently `trySend`-ing
  into a void; subscribers will never silently miss EVENT/EOSE
  again.

- H3 — `BanStore` kind ops serialize through a new `kindLock` so
  concurrent admin RPCs and policy reads see consistent state.
  `allowKind` and `disallowKind` are now symmetric: each removes the
  kind from the opposite set, so `allowKind(K)` after `disallowKind(K)`
  actually re-allows K.

- H4 — `InProcessWebSocket` reconnect after disconnect is now
  supported. Each `connect()` allocates a fresh CoroutineScope +
  drainer channel, so a prior `disconnect()` (which cancelled the
  previous scope) doesn't leave the new connect with a dead drainer.

- H5 — `Nip86Server.dispatch` re-throws `CancellationException`
  through the runCatching's getOrElse so structured concurrency
  works (cancellation no longer reported as an RPC error).

- M1 — `LiveEventStore.query` dedupes events between the historical
  replay and the live SharedFlow during the in-flight overlap window.
  Events seen during `store.query` are tracked in a transient set and
  filtered out of the live stream until EOSE; the set is dropped after
  EOSE so live-only events don't accumulate memory.

## Operator-facing (audit Medium / Low)

- L4 — Audit-trail log: every admin RPC writes a single structured
  line to stderr (pubkey + method + ok/error).

- L3 — RelayConfig.resolveInfo() default supported_nips list now
  matches RelayInfo.default() (both advertise NIP-86).

- M2 — Hex-id and pubkey params validated as 64-char hex on
  ban/allow/list methods; malformed input returns "invalid params"
  instead of being silently stored.

- M4 — argparse supports `--key=value` form in addition to
  `--key value`.

- M5 — `RelayHub.close()` is idempotent and rejects subsequent
  `getOrCreate` calls so a relay created mid-shutdown can't leak its
  store.

- M7 — `LocalRelayServer.stop()` sets a `shuttingDown` flag *before*
  notifying clients; new WebSocket upgrades during the grace window
  are rejected so they don't miss the NOTICE.

- L5 — Shutdown hook runCatching-wraps both `server.stop()` and
  `relay.close()` so a throw in one doesn't skip the other.

- `--verify` was renamed to `--no-verify` semantically (verify is
  the default). The `--verify` flag is no longer documented.

## On-disk persistence (operator request)

New `RelayStateStore` writes a single JSON sidecar file holding the
live NIP-11 doc + all NIP-86 ban / allow / kind lists. Atomic write
via temp + ATOMIC_MOVE rename so a crash mid-save can never leave the
file half-written.

- `BanStore` now takes an optional `onMutation: () -> Unit` callback;
  every mutation fires it. `Relay` wires it to `snapshot()` which
  captures BanStore + info into a `RelayPersistedState` and calls
  `RelayStateStore.save`.
- `Relay` accepts a new `stateFile: File?` constructor arg. At
  construction it loads the snapshot via `RelayStateStore.load` and
  seeds the in-memory state — using a private `seedFromSnapshot`
  bulk-load that bypasses the mutation callback so we don't write
  back what we just read.
- New config field `[admin].state_file = "/var/lib/quartz-relay/events.db.admin.json"`.
  Convention: place next to the SQLite event-store file.
- Corrupt state files are tolerated: log to stderr and start fresh
  (refuse to silently overwrite operator data).

## Tests

8 new persistence tests: cold-boot writes nothing, ban/allow/info
round-trip across restart, kind allow/deny round-trip, corrupt-file
recovery, atomic write (no leftover .tmp), in-memory mode when no
state file is configured, full RelayStateStore round-trip across all
sections.

Total :quartz-relay tests: 95, 0 failures.
2026-05-07 03:22:56 +00:00
Claude 7c7908d373 feat(relay): NIP-86 relay management API
Implements the operator-facing JSON-RPC admin protocol on the relay
side, layered on top of the existing NIP-98 HTTP-Auth + NIP-86 wire
types in :quartz. Same path as the NIP-01 WebSocket and NIP-11 GET —
HTTP POST with Content-Type application/nostr+json+rpc, gated by a
NIP-98 Authorization header signed by an operator-listed pubkey.

Architecture:
  - BanStore           — concurrent in-memory state for runtime
                         pubkey/event/kind ban + allow lists.
  - DynamicBanPolicy   — IRelayPolicy that consults BanStore on every
                         EVENT; auto-prepended to every Relay's policy
                         stack so admin actions take effect without a
                         restart.
  - Nip98AuthVerifier  — parses `Authorization: Nostr <base64-event>`,
                         verifies kind 27235 + Schnorr sig + ±60 s
                         clock skew + method/url/payload-hash match.
  - Nip86Server        — JSON-RPC dispatcher. Mutates BanStore for
                         ban/allow/list methods, mutates the live
                         RelayInfo doc for changerelayname/desc/icon
                         (Relay.info is now @Volatile var, mutation
                         through Relay.updateInfo).
  - LocalRelayServer   — adds POST handler at the relay path:
                         403 if no admin pubkeys configured,
                         401 if NIP-98 missing/invalid,
                         403 if signer's pubkey isn't on the admin list,
                         200 with Nip86Response otherwise.
  - RelayConfig.AdminSection — `[admin].pubkeys = [...]` config key.
  - RelayInfo.default() now advertises NIP-86 alongside 1/9/11/40/42/45/50/62.

Methods implemented:
  supportedmethods, banpubkey, unbanpubkey, listbannedpubkeys,
  allowpubkey, unallowpubkey, listallowedpubkeys,
  banevent (also deletes from store), allowevent, listbannedevents,
  allowkind, disallowkind, listallowedkinds,
  changerelayname, changerelaydescription, changerelayicon.

Tests (28 new):
  - BanStoreTest (6)            — ban/allow round-trips, case-insensitive
                                  pubkey match, kind allow/deny precedence,
                                  audit-trail listing.
  - Nip86ServerTest (8)         — every method's accept/reject path.
  - Nip98AuthVerifierTest (8)   — happy path, missing/wrong scheme,
                                  url/method/payload-hash mismatch,
                                  stale created_at, wrong event kind.
  - Nip86EndToEndTest (6)       — real HTTP POST through LocalRelayServer:
                                  supportedmethods returns 200,
                                  outsider returns 403, no auth header
                                  returns 401 + WWW-Authenticate, banpubkey
                                  blocks subsequent EVENT publish over WS,
                                  changerelayname flows to NIP-11 GET,
                                  admin endpoint is disabled when no
                                  pubkeys configured.

Total :quartz-relay tests: 87, 0 failures.
2026-05-07 02:55:47 +00:00
Claude 9ebdfc0b81 feat(relay): drop max_event_bytes + rate-limit configs; verify on by default
Removes config keys + policies that don't earn their complexity:
  - [limits].max_event_bytes / MaxEventBytesPolicy — duplicates
    [limits].max_ws_frame_bytes which is the right layer (Ktor frame
    cap at the wire), and the policy-level check ran AFTER the event
    was already parsed and bound for the store.
  - [limits].messages_per_sec, [limits].subscriptions_per_min /
    RateLimitPolicy — per-session token buckets without the matching
    per-IP / global-EPS infrastructure are mostly cosmetic; an
    operator who needs real rate limits will use a reverse proxy.

Also flips [options].verify_signatures default from `false` to `true`.
A relay accepting traffic from real clients should verify Schnorr
signatures, and verify-by-default closes the footgun of forgetting
the flag. The CLI gains `--no-verify` for explicit opt-out
(test fixtures, mirror replays); the old `--verify` is dropped (a
no-op now anyway).

Removed:
  - quartz-relay/.../policies/MaxEventBytesPolicy.kt
  - quartz-relay/.../policies/RateLimitPolicy.kt
  - 7 obsolete tests in PoliciesTest + 1 in PoliciesIntegrationTest
  - The matching config fields in LimitsSection
  - Sample config entries in config.example.toml
  - Wiring in Main.composePolicy

Added:
  - Test verifySignaturesCanBeExplicitlyDisabled covering the
    new explicit-opt-out path.

Total :quartz-relay tests: 59, 0 failures.
2026-05-07 02:36:18 +00:00
Claude 633d0c3c74 feat(relay): enforce [limits] + [authorization] config sections
Wires the parsed-but-unenforced config sections into actual relay
behavior via five new IRelayPolicy implementations under
quartz-relay/.../policies/. They compose through the existing
PolicyStack so operators stack only what they need; cheap rejection
paths run before expensive ones (rate-limit → AUTH → future/size/lists
→ signature verification).

New policies:
  - KindAllowDenyPolicy           — kind_whitelist + kind_blacklist
  - PubkeyAllowDenyPolicy         — pubkey_whitelist + pubkey_blacklist
                                    (case-insensitive)
  - RejectFutureEventsPolicy      — options.reject_future_seconds
  - MaxEventBytesPolicy           — limits.max_event_bytes
                                    (size of canonical NIP-01 JSON form)
  - RateLimitPolicy               — token-bucket per session for
                                    messages_per_sec + subscriptions_per_min;
                                    monotonic clock so wall-time jumps
                                    don't reset buckets.

Plus:
  - LocalRelayServer now honors limits.max_ws_frame_bytes /
    limits.max_ws_message_bytes via Ktor's WebSockets.maxFrameSize.
  - Main.kt builds the policy stack from RelayConfig in composePolicy().
    Warning surface is reduced to only the three sections still
    pending (max_subscriptions_per_session, max_filters_per_req,
    network.remote_ip_header).
  - PassThroughPolicy base lets each policy declare only the hook(s)
    it actually enforces, keeping call sites readable.

Tests (20 new):
  - PoliciesTest (16) — per-policy unit coverage for each accept/reject
    boundary, including allow + deny precedence, case-insensitive pubkey
    matching, deterministic rate-limit refill via injected clock, and
    composition via IRelayPolicy.plus.
  - PoliciesIntegrationTest (4) — end-to-end through NostrClient →
    RelayHub → Relay; proves OK false comes back over the wire when
    policies reject (kind blacklist, pubkey allow-list, future
    timestamps, oversize events).

Total :quartz-relay tests: 62, 0 failures.

Also: bump Nip40 deleteExpiredEvents wait from 1500ms to 2500ms — the
previous margin was thin enough to flake on busy CI when the SQLite
unixepoch() rounds down across the wait.
2026-05-07 02:14:42 +00:00
Claude e214143def feat(relay): TOML config file (--config /path/to/relay.toml)
Adds operator-facing TOML configuration to :quartz-relay, with the
section layout deliberately mirroring nostr-rs-relay's config.toml so
existing operators can port across with little churn.

Sections parsed AND enforced today:
  [info]      — NIP-11 doc fields (name, description, contact, pubkey,
                 software, supported_nips, …); replaces the previous
                 hardcoded RelayInfo.default()
  [network]   — host, port, path
  [database]  — in_memory toggle + file path
  [options]   — verify_signatures, require_auth (compose to the right
                 IRelayPolicy stack)

Sections parsed today but NOT YET ENFORCED (forward-compat for the
upcoming rate-limit / authorization work — relay logs a warning when
they're set):
  [limits]         — max_event_bytes, messages_per_sec, …
  [authorization]  — pubkey_whitelist/blacklist, kind_whitelist/blacklist
  [options].reject_future_seconds
  [network].remote_ip_header

CLI flag precedence over the config file is preserved: --host, --port,
--path, --info, --db, --auth, --verify all override the matching field.

Adds:
  - cc.ekblad:4koma 1.2.0 for TOML parsing
  - quartz-relay/config.example.toml as the canonical operator reference
  - 5 unit tests (defaults, full parse, NIP-11 mapping, bundled example
    file, optional sections)
2026-05-07 01:21:58 +00:00