Skip to content

Read-only channel allowlist for NIP-42/43 viewers#896

Open
tlongwell-block wants to merge 6 commits into
mainfrom
eva-max/read-only-channel-allowlist
Open

Read-only channel allowlist for NIP-42/43 viewers#896
tlongwell-block wants to merge 6 commits into
mainfrom
eva-max/read-only-channel-allowlist

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Read-only channel allowlist for NIP-42/43 viewers

What

Adds a relay-level viewer role: a NIP-43 relay member who, upon NIP-42 auth,
is granted read-only scopes restricted to an explicit per-viewer channel
allowlist
. Viewers can read history, search, and receive live events for their
allowed channels only — and cannot publish anything, anywhere.

Why this is minimal & safe

The relay already had every enforcement seam this needs:

  • Read gate (req.rs): WS REQ intersects accessible channels with
    AuthContext.channel_ids. The channel_ids: Option<Vec<Uuid>> field already
    existed, documented "reserved for future per-channel access control" — this PR
    is that future. None = unrestricted (unchanged for normal members).
  • Write gate (ingest.rs): every event kind maps to a *:write/admin:*
    scope via required_scope_for_kind; unknown kinds rejected. A connection whose
    scope set contains no write scopes cannot emit a single state-changing event.
  • Scopes are fixed at auth time, never re-derived from membership — so the
    join-request path (9021, read-scoped) cannot escalate a viewer to a writer.

So read-only is expressed as scopes, not as a new membership permission model.
That rides the existing, tested checks — it constructs a different AuthContext
at the auth call site rather than widening the enforcement surface.

Design

  1. NIP-42 = identity. NIP-43 / relay_members = admission. Unchanged.
  2. New relay_member_channel_allowlist(pubkey, channel_id) table — the explicit
    per-viewer grant set (migration 0002, 0001 left immutable).
  3. In handlers/auth.rs, after check_relay_membership succeeds: if the member's
    role is viewer, build the context with read-only scopes
    (messages:read, channels:read, users:read, files:read — no writes, no admin,
    no proxy:submit) and channel_ids = Some(<allowlist>). Normal members keep
    all_known() / channel_ids = None.
  4. viewer is added to the relay_members.role TEXT CHECK constraint (not the
    channel_members.member_role enum — that's intentionally untouched; guest
    was rejected because message writes are membership-binary today and guest
    would silently fall through to write behavior).

Membership-check refactor (why the diff touches several files)

The old enforce_relay_membership returned only owner-or-deny — it could not
express "admitted, but read-only and channel-restricted." It's replaced by
check_relay_membership returning a MembershipDecision enum
(OpenRelay | Member | Viewer{channel_ids} | ViaOwner | ViaViewerOwner{owner,channel_ids} | Denied).
Every entrypoint (WS auth, WS COUNT, HTTP /query//count, bridge, git, media,
mesh, audio) matches on this enum and handles viewers explicitly. The legacy
enforce_relay_membership wrapper was deleted — a pub helper that admits a
viewer while silently dropping the channel restriction is a fail-open footgun for
any future caller, so it's gone rather than left as dead code.

All entrypoints covered (not just WS)

"Zero enforcement change" is only true for WS unless HTTP mirrors it. This PR
applies the same restricted profile + fail-closed #h-scope check across:
WS REQ, WS COUNT, HTTP /query, HTTP /count, and bridge search/feed paths.
Malformed/empty/unscoped/disallowed #h fails closed for restricted viewers;
search runs with include_global=false. Viewers are denied git, media upload,
mesh signaling, and relay admin; audio is bounded to an allowlisted parent channel.

Security audit (all closed)

Edge Status Why
Live fanout safe delivery only to registered subs; registration is access-gated and intersected with channel_ids — a viewer can't register a sub outside the allowlist
Channel→global leak safe scoping invariant: global subs never indexed for channel events
Write bypass safe every write kind needs *:write/admin:*; membership ≠ scope
Discovery/roster leak safe NIP-29 39000-39003 (incl. member list) stored channel-scoped → ride the read gate
Repo/git, media upload, mesh, relay-admin, audio safe viewer scopes deny all; audio checked against effective parent channel

Decided policy (v1)

  • Strict visibility: viewers see only their explicit channel list — no open
    channels, no global subscriptions. Fail-closed.
  • Audio: a viewer may join audio bounded to an allowlisted parent channel.
    This is channel presence, not listen-only media semantics; true listen-only is
    a follow-up if product needs it.
  • Media: rendered event URLs remain readable; no media upload/write for
    viewers. No storage-layer channel ACL added here (no clean event→blob ownership
    metadata to enforce against yet).

Testing

Verified independently on the reviewer's machine (Hermit toolchain):

  • bin/cargo fmt --check — clean
  • bin/cargo clippy -p sprout-relay -p sprout-auth -p sprout-db — zero warnings
  • bin/cargo test -p sprout-relay296 passed, 0 failed
  • bin/cargo test -p sprout-auth -p sprout-db36 + 64 passed

Unit coverage: read-only scope set (all :read, no write/proxy/repos); allowed
channel; disallowed channel; unscoped restricted filter; malformed #h;
unrestricted global behavior (normal members unaffected); viewer denied mesh;
viewer-owner delegation denied mesh.

Follow-up (not blocking the design)

  • Integration tests around the DB-backed viewer-role/allowlist admission path
    end-to-end (the unit layer is covered; DB-backed admission is the remaining gap).

Co-developed by Max (implementation) and Eva (design/audit/review).

@tlongwell-block tlongwell-block requested a review from a team as a code owner June 7, 2026 19:40
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta added 3 commits June 7, 2026 15:51
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block force-pushed the eva-max/read-only-channel-allowlist branch from 213e772 to 231c89f Compare June 7, 2026 19:55
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d added 3 commits June 7, 2026 17:45
Mint a synthetic read-only viewer AuthContext for unauthenticated
WebSocket REQ/COUNT when SPROUT_PUBLIC_VIEWER_CHANNELS is configured,
scoped to exactly the listed channels. Empty (default) keeps public
read disabled and rejects unauthenticated reads as before.

The allowlist is config-driven (no DB schema): the public set is not
identity, so it is not stored in relay_members. The synthetic context
uses a reserved sentinel pubkey that is never authenticated, never
looked up in relay_members, and never used for accounting; security
comes entirely from the channel_ids pin plus read-only scopes.

Public viewers' accessible set is the allowlist directly, bypassing the
membership UNION (which would under-grant non-open channels). HTTP
query/count stay NIP-98-gated; public read is WS-only. Reuses the
existing #896 filter-scoping gate unchanged, so REQ/COUNT cannot read
global, disallowed, or malformed-#h filters and cannot EVENT/write.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Two fail-closed fixes to the unauthenticated public-viewer read path on the
NIP-42/43 gated relay, both reproduced live before and after:

1. Deleted-channel revival. Public viewers set accessible_channels directly
   from SPROUT_PUBLIC_VIEWER_CHANNELS, bypassing the deleted_at IS NULL guard
   every DB-backed path gets. Stale config could revive reads on a soft-deleted
   channel. Add Db::filter_active_channel_ids and intersect the config allowlist
   through it in the WS REQ/COUNT public-viewer branches. Fail closed on DB
   error / empty set.

2. Global-subscription fanout leak. A restricted viewer's REQ naming distinct
   allowed channels across filters ({#h:[A]} + {#h:[B]}) resolves to channel_id
   == None and registered in the global fanout index, letting a stray-h-tagged
   global event be live-delivered. Reject any restricted-viewer subscription
   that does not pin to exactly one concrete allowed channel before
   registration. (Builds on the existing per-filter multi-#h reject; the
   distinct-filter case slipped past it.)

Tests: filter_active_channel_ids_excludes_deleted_and_unknown (Postgres),
restricted_viewer_distinct_allowed_channel_filters_resolve_global. Full
sprout-relay suite: 302 pass. Live breakout: control read OK; deleted-channel,
two-distinct-#h, and multi-#h all CLOSED with no secret/global delivery.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
The HTTP bridge /query presence shortcut (synthesize_presence) ran before
the per-filter channel-scope gate, so a restricted (viewer-role) caller
could POST a presence filter (kind:20001/40902 + authors, no #h) and
receive relay-signed presence for arbitrary pubkeys, bypassing the
channel-scope rule. Presence is keyed by pubkey, not channel, so it
cannot be channel-scoped.

Fix: skip presence synthesis when restricted_to_channel_allowlist is set.
The presence filter then falls through to filter_has_allowed_channel_scope,
which rejects it (no allowed #h) with FORBIDDEN — fail closed. Full members
are unaffected.

Found by Perci and Dawn in the PR #896 security audit (authed-viewer
channel-scope bypass; the unauthenticated WS path was never affected since
verify_bridge_auth gates every bridge handler).

Test: restricted_viewer_presence_filter_is_rejected. sprout-relay suite:
303 pass.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant