Skip to content

feat(relay): NIP-OA agent authentication for NIP-43 membership (WS + REST + git)#471

Merged
tlongwell-block merged 3 commits intomainfrom
feat/nip-oa-nip43-auth
May 4, 2026
Merged

feat(relay): NIP-OA agent authentication for NIP-43 membership (WS + REST + git)#471
tlongwell-block merged 3 commits intomainfrom
feat/nip-oa-nip43-auth

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented May 4, 2026

Summary

Allow NIP-OA bearing agents to authenticate to relays that enforce NIP-43 membership — across all transport paths: WebSocket, REST API, and git.

The relay verifies the agent's NIP-42 AUTH (WS) or X-Auth-Tag header (HTTP), cryptographically verifies the owner attestation, and checks the owner's pubkey for relay membership — granting the agent access without a persistent member record.

How it works

WebSocket (NIP-42)

Agent connects → relay sends NIP-42 challenge
    ↓
Agent signs kind:22242 with NIP-OA ["auth", owner, conditions, sig] tag
    ↓
Relay verifies NIP-42 (agent's signature, challenge, relay URL, freshness)
    ↓
Relay detects auth tag → verifies NIP-OA Schnorr sig (owner attested this agent)
    ↓
Relay checks owner's pubkey for NIP-43 membership
    ↓
Agent gets session-scoped access

REST / Git (X-Auth-Tag header)

Agent sends HTTP request with:
  X-Pubkey: <agent_hex>
  X-Auth-Tag: ["auth","<owner_hex>","<conditions>","<sig_hex>"]
    ↓
Relay authenticates via X-Pubkey (dev mode) or Bearer token
    ↓
enforce_relay_membership: agent not a direct member?
    ↓
Parse X-Auth-Tag → verify NIP-OA sig against agent pubkey
    ↓
Check owner's pubkey for relay membership → grant access

Changes

File What
sprout-auth/src/lib.rs AuthMethod::Nip42OwnerAttestation variant + owner_pubkey field
sprout-relay/src/config.rs allow_nip_oa_auth config flag (SPROUT_ALLOW_NIP_OA_AUTH=true)
sprout-relay/src/handlers/auth.rs WS: NIP-OA tag extraction, verification, membership routing
sprout-relay/src/api/relay_members.rs REST: NIP-OA fallback in enforce_relay_membership
sprout-relay/src/api/mod.rs extract_single_auth_tag helper (rejects duplicates)
sprout-relay/src/api/git/transport.rs Git: X-Auth-Tag for NIP-98 auth path
sprout-relay/src/api/media.rs Media: X-Auth-Tag for upload auth
sprout-relay/src/api/tokens.rs Tokens: X-Auth-Tag for NIP-98 mint path
sprout-relay/src/audio/handler.rs Audio: pass None (WS-only, no HTTP headers)
sprout-acp/src/relay.rs ACP: apply_auth_with_tag, RestClient.auth_tag field
sprout-mcp/src/relay_client.rs MCP: apply_auth sends X-Auth-Tag
sprout-sdk/examples/compute_auth_tag.rs Utility for generating NIP-OA auth tags
sprout-relay/Cargo.toml sprout-sdk dependency

Design decisions

  • Opt-in — disabled by default. Relay operators must set SPROUT_ALLOW_NIP_OA_AUTH=true.
  • Stateless — REST path verifies the tag per-request (no session coupling, no DB state for agent→owner).
  • Fail-closed — invalid OA tag → rejection. Verification errors fall through to 403.
  • Multiple auth tags rejected — WS rejects events with 2+ auth tags; REST rejects duplicate X-Auth-Tag headers (400).
  • Session-scoped — no persistent member record for agents. Access depends on owner's membership.
  • Owner attributionAuthContext.owner_pubkey carries the owner for downstream audit/rate-limiting.

E2E Testing

Tested with agents that are NOT relay members, authenticated purely via NIP-OA:

Test Result
WS auth (NIP-OA owner attestation)
REST: channel discovery
REST: presence set + heartbeat
REST: user profile lookup
Task receipt + reply via WS
Git push (NIP-98 + X-Auth-Tag)
Git clone
Negative: no auth tag → 403
Negative: non-member owner → 403
Negative: wrong agent pubkey → 403
Negative: malformed tag → 403
Negative: duplicate headers → 400
Unit tests (340) ✅ all pass

Future work

  • Rate limiting aggregated by owner pubkey
  • Owner removal → agent session revocation (WS)
  • Integration test suite for NIP-OA paths

Allow agents bearing a valid NIP-OA auth tag to authenticate to
NIP-43-enforcing relays. The relay verifies the agent's NIP-42 AUTH
first, then verifies the embedded NIP-OA tag, and checks the *owner's*
pubkey for relay membership instead of the agent's.

Key design:
- Gated by SPROUT_ALLOW_NIP_OA_AUTH=true (default: false)
- Agent gets session-scoped access (no persistent member record)
- AuthContext carries owner_pubkey for downstream attribution
- Rejects events with multiple auth tags (spec: exactly 0 or 1)
- Fail-closed on verification errors
- WebSocket path only (REST/audio/git are future work)

Files changed:
- sprout-auth: AuthMethod::Nip42OwnerAttestation + owner_pubkey field
- sprout-relay/config: allow_nip_oa_auth config flag
- sprout-relay/handlers/auth: NIP-OA detection, verification, membership routing
- sprout-relay/Cargo.toml: sprout-sdk dependency
Thread the auth_tag through the NIP-42 AUTH response path in both
sprout-mcp and sprout-acp. Previously, the auth tag was only injected
into regular events (kind:9, etc.) via sign_event(), but the relay-side
NIP-OA verification looks for it on the kind:22242 AUTH event itself.

Without this fix, agents with NIP-OA credentials would pass NIP-42
verification but the relay would never find the auth tag to verify
owner attestation against.

Changes:
- sprout-mcp: thread auth_tag through build_auth_event → do_connect →
  send_auth_response → handle_ws_message → run_background_task
- sprout-acp: parse SPROUT_AUTH_TAG at connect time, thread through
  send_auth_response and all reconnection paths
@tlongwell-block tlongwell-block force-pushed the feat/nip-oa-nip43-auth branch from f592387 to 9f1abb7 Compare May 4, 2026 15:20
@tlongwell-block tlongwell-block changed the title feat(relay): NIP-OA agent authentication for NIP-43 membership enforcement feat(relay): NIP-OA agent authentication for NIP-43 membership (WS + REST + git) May 4, 2026
The original NIP-OA implementation only covered the WebSocket NIP-42 auth
path. Agents could authenticate on the WS but HTTP API calls (channel
discovery, presence, profiles, git) would 403 because the REST membership
gate had no NIP-OA awareness.

This commit extends NIP-OA to all HTTP endpoints via the X-Auth-Tag header.
Agents send their NIP-OA tag with every HTTP request; the relay verifies
the tag cryptographically and checks the owner's membership — same logic
as the WS path, just triggered by a header instead of a WS event tag.

Design:
- Stateless per-request verification (no session coupling)
- X-Auth-Tag header carries the full JSON auth tag
- enforce_relay_membership gains NIP-OA fallback: if agent isn't a direct
  member AND allow_nip_oa_auth is enabled AND header is present → verify
  tag → check owner membership
- Duplicate X-Auth-Tag headers rejected (400) — mirrors WS path's
  "exactly 0 or 1 auth tags" rule
- Gated behind existing SPROUT_ALLOW_NIP_OA_AUTH=true flag
- Fail-closed on verification errors

Changes:
- sprout-relay/api/relay_members.rs: NIP-OA fallback in enforce_relay_membership
- sprout-relay/api/mod.rs: extract_single_auth_tag helper
- sprout-relay/api/git/transport.rs: X-Auth-Tag for git NIP-98 path
- sprout-relay/api/media.rs: X-Auth-Tag for media upload path
- sprout-relay/api/tokens.rs: X-Auth-Tag for token minting path
- sprout-relay/audio/handler.rs: pass None (audio is WS-only)
- sprout-acp/relay.rs: apply_auth_with_tag, RestClient.auth_tag field
- sprout-mcp/relay_client.rs: apply_auth sends X-Auth-Tag
- sprout-sdk/examples/compute_auth_tag.rs: utility for generating auth tags
@tlongwell-block tlongwell-block force-pushed the feat/nip-oa-nip43-auth branch from 9f1abb7 to 75e6954 Compare May 4, 2026 16:03
@tlongwell-block tlongwell-block merged commit df33818 into main May 4, 2026
13 checks passed
@tlongwell-block tlongwell-block deleted the feat/nip-oa-nip43-auth branch May 4, 2026 16:12
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