Skip to content

fix: unify NIP-OA relay membership enforcement across all ingress paths#490

Merged
tlongwell-block merged 2 commits intomainfrom
fix/unify-nip-oa-relay-membership
May 6, 2026
Merged

fix: unify NIP-OA relay membership enforcement across all ingress paths#490
tlongwell-block merged 2 commits intomainfrom
fix/unify-nip-oa-relay-membership

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Summary

When SPROUT_REQUIRE_RELAY_MEMBERSHIP=true, agents whose owner is a relay member should be allowed to connect via NIP-OA attestation. Previously this fallback only worked for media and git transport endpoints — WebSocket NIP-42 auth, the HTTP bridge, and the audio WebSocket handler all lacked it.

This patch removes the duplicate enforce_ws_relay_membership() function and routes all ingress paths through the single shared enforce_relay_membership() helper that already supports NIP-OA owner-delegation.

What Changed

Ingress Path Before After
WebSocket NIP-42 (handlers/auth.rs) Separate function, no NIP-OA Shared helper with auth tag from signed AUTH event
HTTP bridge /events, /query, /count (bridge.rs) Passed None for auth tag Reads x-auth-tag header
Audio WebSocket (audio/handler.rs) Passed None for auth tag Extracts auth tag from AUTH event
MCP client (relay_client.rs) Had auth_tag but never sent it in NIP-42 Includes tag in all AUTH events (connect + reconnect + mid-session)
MCP media upload (upload.rs) No x-auth-tag header Sends header on PUT requests

Security Properties

  • Fail-closed: DB errors, missing tags, invalid signatures all deny access
  • WS path: auth tag is integrity-protected by the AUTH event's Schnorr signature — tampering fails NIP-42 verification before we ever inspect it
  • HTTP path: verify_auth_tag binds the attestation to the NIP-98-authenticated agent pubkey — cannot replay another agent's tag
  • Spec conformance: events with >1 auth tag are treated as having no valid tag (per NIP-OA spec)
  • No new attack surface: the only new accept path is "valid NIP-OA Schnorr sig + owner is relay member"

Unified Flow

All Ingress → transport auth → extract auth tag → enforce_relay_membership()
                                                         │
                                          ┌──────────────┼──────────────┐
                                          ▼              ▼              ▼
                                    !require_membership  direct member  NIP-OA valid
                                       → ALLOW           → ALLOW       + owner is member
                                                                       → ALLOW
                                          else → DENY (fail-closed)

Testing

  • cargo build --workspace
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • cargo test --workspace ✅ (all 1,251 tests pass)

Stats

6 files changed, +116/−68 (net code reduction from deleting the duplicate function)

When SPROUT_REQUIRE_RELAY_MEMBERSHIP=true, agents whose owner is a relay
member should be allowed to connect via NIP-OA attestation. Previously,
this fallback only worked for media and git transport endpoints. WebSocket
NIP-42 auth had a separate enforce_ws_relay_membership() with no NIP-OA
support, the HTTP bridge passed None for the auth tag, and the audio
WebSocket handler also lacked the fallback.

This patch removes the duplicate enforcement function and routes all five
ingress paths through the single shared enforce_relay_membership() helper
that already supports NIP-OA owner-delegation:

- WebSocket NIP-42 (handlers/auth.rs): extract auth tag from the signed
  AUTH event, pass to shared helper
- HTTP bridge /events, /query, /count (api/bridge.rs): read x-auth-tag
  header instead of passing None
- Audio WebSocket (audio/handler.rs): extract auth tag from AUTH event,
  pass to shared helper
- MCP client (relay_client.rs): include auth_tag in NIP-42 AUTH events
  (initial connect, reconnect, mid-session re-auth)
- MCP media upload (upload.rs): send x-auth-tag header on PUT requests

Additional spec conformance:
- extract_auth_tag_json() rejects events with >1 auth tag per NIP-OA spec

Security properties:
- Fail-closed: DB errors, missing tags, invalid sigs all deny access
- WS path: auth tag is integrity-protected by the event Schnorr signature
- HTTP path: verify_auth_tag binds attestation to the NIP-98-authenticated
  agent pubkey (cannot replay another agent's tag)
- No new attack surface: the only new accept path is valid NIP-OA sig +
  owner is relay member, which is the intended behavior
The ACP has its own WebSocket relay client (separate from sprout-mcp's
RelayClient). It was not including the NIP-OA auth tag in NIP-42 AUTH
events or sending x-auth-tag on HTTP bridge requests, so agents using
SPROUT_AUTH_TAG could not connect to membership-enforced relays.

Changes:
- Parse SPROUT_AUTH_TAG into a nostr::Tag and pass to HarnessRelay::connect
- Thread auth_tag through do_connect, send_auth_response, handle_ws_message,
  try_autonomous_reconnect, wait_for_reconnect, process_handshake_buffer
- Add auth_tag_json field to RestClient, send x-auth-tag header on all
  HTTP bridge POST requests (/query, /events)

E2E verified: agent with NIP-OA tag connects, discovers channels, receives
tasks, and replies — all through a relay with SPROUT_REQUIRE_RELAY_MEMBERSHIP=true.
@tlongwell-block tlongwell-block merged commit 2357c3d into main May 6, 2026
14 checks passed
@tlongwell-block tlongwell-block deleted the fix/unify-nip-oa-relay-membership branch May 6, 2026 15:01
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