feat(relay): implement NIP-AA agent authentication via NIP-OA credentials#468
Closed
tlongwell-block wants to merge 33 commits into
Closed
feat(relay): implement NIP-AA agent authentication via NIP-OA credentials#468tlongwell-block wants to merge 33 commits into
tlongwell-block wants to merge 33 commits into
Conversation
… points Implements NIP-AA (Agent Authentication via NIP-OA Credentials) at all 7 relay ingress points. When a pubkey is not a direct relay member, the relay checks for a valid NIP-OA auth tag whose owner IS a member, granting virtual membership without writing a persistent record. Changes: - New nip_aa module with verify_nip_aa() implementing Steps 3-5 - AuthMethod::Nip42AgentAuth variant + owner_pubkey field on AuthContext - WebSocket AUTH handler: NIP-AA fallback in enforce_ws_relay_membership - REST enforcement: NIP-AA fallback in enforce_relay_membership - NIP-98 token bootstrap: auth tag extraction from event JSON - Blossom media upload: auth tag extraction from kind:24242 event - Git transport: auth tag extraction from NIP-98 event - Audio WebSocket: auth tag extraction before event consumption All 254 unit tests pass, clippy clean.
- media.rs and audio/handler.rs now reject multiple auth tags (previously used .find() which silently took the first) - Added clarifying comment on auth.rs error prefix (auth-required: is correct for standard NIP-42; invalid: applies only within NIP-AA verification context)
When require_token=true, verify_auth_event rejects events without an auth_token tag before NIP-AA can be checked. This adds a dedicated NIP-AA path that intercepts AUTH events with a NIP-OA auth tag but no auth_token, performing NIP-42 binding verification directly before proceeding to the NIP-AA membership check. Applied to both WebSocket AUTH handler and audio WebSocket handler.
- Add NIP-AA §6 credential replacement doc comment in auth.rs - Upgrade REST NIP-AA success logging to info! for audit visibility - Extract extract_single_auth_tag helper for testability - Add 4 new unit tests covering edge cases (no tags, non-auth tags, multiple tags, happy path extraction) - Total: 258 unit tests passing, 15 in nip_aa module
…via NIP-AA path Direct members with a dummy auth tag could skip verify_auth_event (which enforces Okta JWT requirements) by triggering the NIP-AA path. Now the NIP-AA path only grants access for actual virtual members (Some(Some(owner))). Direct members (Some(None)) fall through to the standard verify_auth_event path which enforces token requirements. Applied to both WebSocket AUTH handler and audio WebSocket handler.
… auth tags - Virtual members now get Scope::nip_aa_virtual_member() which excludes admin scopes (AdminChannels, AdminUsers) per NIP-AA spec requirement that virtual members MUST NOT gain admin privileges. - Multiple auth tags now explicitly rejected on all non-WS paths (tokens, media, git, audio) with proper error messages, matching the WS path behavior. 258 unit tests pass, clippy clean.
…ment re-auth deviation - auth.rs: strip admin scopes when NIP-AA grants access on API token path (was keeping token scopes as-is, which could include AdminChannels/AdminUsers) - auth.rs: strip admin scopes when NIP-AA grants access on Okta/JWT path (was spreading auth_ctx scopes, which could include admin scopes) - nip_aa.rs: add lowercase hex validation for owner pubkey and sig before calling verify_auth_tag (secp256k1 accepts uppercase; NIP-OA requires lowercase) - relay_members.rs: same lowercase hex validation for REST NIP-AA path - auth.rs: document the deliberate NIP-AA §6 re-auth spec deviation with TODO - remove leftover crates/git-sign-nostr/src/main.rs.bak
Critical fixes per codex round 2:
1. Remove NIP-AA from REST paths (tokens, media, git transport):
- NIP-AA is spec-defined for NIP-42 kind:22242 WS AUTH only
- REST paths (NIP-98, Blossom, git HTTP) now pass None for auth_tag_json
- Remove dead helper functions extract_auth_tag_from_event_json and
extract_created_at_from_event_json from tokens.rs and transport.rs
2. Fix scope widening on API token + Okta/JWT NIP-AA paths:
- Was: replace scopes with nip_aa_virtual_member() (could widen read-only token)
- Now: intersect original scopes with nip_aa_virtual_member() set
- Read-only tokens stay read-only; admin scopes are stripped
Audio WS handler retains NIP-AA (it uses NIP-42 kind:22242 with challenge)
…t API contract - nip_aa.rs: log DB errors server-side; send sanitized fail-closed message to client (was leaking internal DB error details in restricted: reason string) - sprout-media/src/error.rs: remove unused MultipleAuthTags variant (was added for NIP-AA REST path that has since been removed) - relay_members.rs: add doc comment warning that callers passing auth_tag_json MUST have pre-verified the NIP-42 AUTH event (Step 1); REST paths must pass None - audio/handler.rs: log owner_pubkey on NIP-AA virtual membership grant with comment explaining owner-scoped session tracking limitation
…ubkey check NIP-AA requires every EVENT pubkey to be an authenticated pubkey holding active or virtual membership. The gift-wrap exception (kind:1059) allowed any authenticated user to submit events with arbitrary outer pubkeys for privacy. This is correct for real members but wrong for NIP-AA virtual members — they should only submit events signed by their own pubkey. Fix: extract auth_method from AuthContext in event.rs; disable the gift-wrap exception when auth_method == Nip42AgentAuth (is_nip_aa_virtual). Virtual members hitting the gift-wrap path now get the standard pubkey mismatch rejection.
…; audio uses nip_aa directly - relay_members.rs: strip NIP-AA logic entirely from enforce_relay_membership (REST-only helper is now direct-membership-only, no footgun for callers) Return type simplified from Result<Option<PublicKey>> back to Result<()> - audio/handler.rs: call handlers::nip_aa::verify_nip_aa directly instead of routing through enforce_relay_membership with auth_tag_json params (NIP-AA logic now only in nip_aa.rs and the WS auth handler) - auth.rs: use 'invalid:' prefix for NIP-42 Step 1 failures when the event carries an auth tag (NIP-AA attempt); 'auth-required:' for standard NIP-42 - auth.rs: revert AuthState::Authenticated to tuple variant (struct variant was a partial re-auth implementation that broke compilation) - connection.rs: revert AuthState::Authenticated to tuple variant - event.rs, req.rs: fix AuthState::Authenticated pattern matches to tuple
- audio/handler.rs: NIP-AA virtual members were being rejected by the direct-only relay membership gate after nip_aa::verify_nip_aa already confirmed their owner is an active member. Fix: track is_nip_aa_virtual flag and skip the direct membership gate for virtual members. - nip_aa.rs: fix doc comment — >1 auth tags returns Err, not None
…ay_members cleanup - AuthState::Authenticated now retains challenge for NIP-AA §6 re-auth - Same-pubkey re-auth replaces stored credential; different pubkey rejected - enforce_ws_relay_membership: DB error now fails closed immediately (no NIP-AA fallback) - Scope intersection with NIP-AA virtual member set: empty intersection denied - Pubkey-only/NIP-98 path: use full virtual-member scope set (not empty intersection) - auth_token + auth tag interaction: documented with comment and unit test - relay_members.rs: removed all NIP-AA logic, simplified to direct-only - audio/handler.rs: NIP-AA virtual members skip second relay membership gate - scope.rs: renamed test all_known_returns_all_14_variants -> all_known_returns_all_16_variants
…il-closed DB errors
NIP-AA §6 compliance:
- connection.rs: AuthState::Authenticated now carries {ctx, challenge} so the
challenge is available for re-auth verification without a new round-trip
- auth.rs: same-pubkey re-auth is now supported — extracts prev_auth_ctx and
restores it on failure (failed re-auth must not invalidate prior identity per spec)
- auth.rs: reauth_fail! macro restores previous AuthContext on re-auth failure
instead of transitioning to AuthState::Failed
Scope safety:
- auth.rs (API token path): deny empty scope intersection rather than granting
with empty scopes (empty scopes treated as unrestricted in some handlers)
- auth.rs (Okta/JWT path): pubkey-only auth has empty scopes — use full
virtual-member scope set instead of empty intersection for NIP-AA virtual members
Fail-closed DB errors:
- auth.rs (enforce_ws_relay_membership): DB errors now fail closed immediately
with a metric + rejection, not fall through to NIP-AA fallback
…n audio+ConnectionManager
…fail-open DB bug Issue 2: Replace fragile string-prefix heuristic for malformed AUTH detection with targeted "AUTH" (quoted) scan — no magic numbers, position-independent. Issue 3: Audio handler now calls canonical extract_single_auth_tag() from nip_aa.rs instead of reimplementing tag extraction inline. Also fixes a security bug where unwrap_or(false) on DB errors silently granted audio access (fail-open); now fails closed matching the main WS handler.
Remove the hard rejection of different-pubkey re-auth. The NIP-AA spec envisions multi-pubkey per connection but doesn't mandate it. The previous behavior of rejecting different-pubkey AUTH was overly restrictive. Now: re-auth with any pubkey (same or different) replaces the current credential. Failed re-auth preserves the existing identity per NIP-AA §6. This is a valid spec subset — documented with a clear upgrade path to HashMap<PublicKey, AuthContext> if simultaneous multi-identity is needed.
…tate 7 new tests (284 total): - Scope intersection: admin stripped, read/write preserved, virtual member set correct - ConnectionManager: owner-pubkey tracking, deregister cleanup, multi-connection same owner - Auth state: pubkey replacement clears old index Also updates E2E test TODO in nip_aa.rs with cross-references to new unit tests.
…ppy fix Critical: AudioPeer now stores owner_pubkey for NIP-AA virtual members, satisfying the spec requirement to retain owner pubkey in session state. Previously audio sessions were invisible to owner-scoped enumeration. Also: remove unnecessary Some/unwrap_or wrapper (clippy), document validate_auth_tag_hex defense-in-depth rationale, document evaluate_created_at_conditions upstream dependency.
…paths Both the API-token and Okta/pubkey-only auth paths stored owner_pubkey in AuthContext but never called ConnectionManager.set_owner_pubkey(). This made NIP-AA virtual members authenticated via these paths invisible to connection_ids_for_owner — owner-scoped session enumeration/termination would silently miss them. The pure NIP-AA path (line ~492) already called both; now all three auth paths are consistent.
…s per NIP-OA spec - Add clear_owner_pubkey to ConnectionManager; call it when re-auth produces a non-NIP-AA credential (prevents stale owner_to_conns entries) - evaluate_created_at_conditions now rejects empty clauses (from leading/ trailing &) and unknown clause types per NIP-OA MUST requirement - kind= clauses remain correctly skipped at admission per NIP-AA spec - 285 tests passing, zero clippy warnings
…refix, scope breadth, malformed AUTH handling C1 (critical): Clear subscriptions on identity-switch re-auth to prevent privilege combination — a client that re-auths as a different pubkey must not retain subscriptions from the previous identity. Fixes NIP-AA §6 violation. H1 (high): Malformed AUTH now only closes the WebSocket when no event id can be extracted. If the event id is parseable, send OK false instead. M1 (medium): Strip 'restricted:' prefix from evaluate_created_at_conditions return values — the caller adds the prefix. Fixes double-prefix bug. Also fix task-panic error prefix from 'auth-required:' to 'invalid:'. M3 (medium): Replace all_non_admin() with explicit allowlist for NIP-AA virtual member scopes. Excludes JobsRead/Write, SubscriptionsRead/Write, UsersWrite, ReposWrite.
…e, audio ConnectionManager registration, owner removal agent disconnect - Clear subscriptions on ANY re-auth (not just pubkey change) to prevent privilege leakage when re-authing with narrower scopes (NIP-AA §6) - Transition to AuthState::Failed on first-auth membership denial instead of leaving connection in Pending state forever - Register audio NIP-AA connections in ConnectionManager with owner_pubkey so connection_ids_for_owner() can enumerate them - Add owner-removal agent disconnect enumeration with TODO for close_connection API (ConnectionManager lacks public disconnect method)
…moval termination - Reorder audio handler to check direct membership before NIP-AA tag validation - Add ConnectionManager::close_connection() for owner-scoped session termination - Wire owner removal in relay_admin.rs to actually disconnect agent connections
…oval agent termination, subscription clearing, fail-closed kind= rejection
… to close subscription race window
…s, detailed server-side logs
…ck confused-deputy on Okta/JWT path
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements NIP-AA (Agent Authentication), allowing agents to inherit relay access from their owner's membership by presenting a NIP-OA credential during NIP-42 authentication. No separate agent enrollment is required — if the owner is a member, the agent gets a virtual membership scoped to the session. This completes the agent identity stack: NIP-OA (#406) establishes key provenance, NIP-AA activates it at the relay boundary.
Motivation
Agents acting on behalf of users need relay access, but manually enrolling every agent key is operationally untenable. NIP-AA solves this by piggybacking on the owner's existing membership: the agent presents a signed NIP-OA credential during NIP-42 auth, the relay verifies the credential and the owner's membership, and grants a virtual session — no persistent record, no enrollment step. This is the minimal viable path to first-class agent support without expanding the membership surface.
What Changed
Core NIP-AA verification (
crates/sprout-relay/src/handlers/nip_aa.rs) — new, 529 linesSix-step verification algorithm:
kind=22242, validid/sig, relay tag, challenge match, ±120s freshnessauthtag — zero means not an agent; more than one is rejectedcreated_atcondition evaluationNIP-42 auth handler (
crates/sprout-relay/src/handlers/auth.rs)verify_nip_aa()into the main WebSocket auth flowAUTHframes now close the WebSocket connection (per spec)Audio WebSocket (
crates/sprout-relay/src/audio/handler.rs,audio/room.rs)AudioPeergains anowner_pubkeyfield for owner-scoped session trackingConnection state (
crates/sprout-relay/src/connection.rs,state.rs)ConnectionManagerstoresowner_pubkeyper connectionScopes (
crates/sprout-auth/src/scope.rs)AdminChannelsandAdminUsersexcludedDesign Decisions
Virtual membership, no persistence. Agent access lives entirely in session state. There is no
agentstable, no enrollment API. If the owner's membership is revoked, the agent's next connection attempt fails. Existing sessions are not terminated (owner-scoped termination is deferred — see below).Single shared
verify_nip_aa(). All three ingress points (main WebSocket, API-token path, audio WebSocket) call the same function. No duplicated verification logic.Scope intersection on token + NIP-AA. If an agent presents both an API token and a NIP-OA credential, the resulting scope set is the intersection of both. This prevents NIP-AA from widening a restricted token's permissions.
Single-pubkey-at-a-time model. Re-authenticating with a different pubkey replaces the current identity. This is a spec-compliant subset of the full multi-pubkey model (HashMap per connection), which is deferred.
Failed re-auth is non-destructive. A failed re-auth attempt does not invalidate the current authenticated session. The connection stays authenticated as the previous identity.
Fail-closed on DB errors. Any database error during membership lookup results in an auth denial. No partial or optimistic grants.
Error prefix compliance. NIP-42 step failures emit
"invalid: <reason>"; NIP-AA-specific rejections (steps 3–5) emit"restricted: <reason>".Testing
284 unit tests passing, zero Clippy warnings.
Unit test coverage includes:
Not covered (deferred):
Related PRs
Future Work
The following are explicitly deferred and documented in code:
kind=enforcement — the NIP-AA spec marks this optional; not implementedHashMap<PublicKey, AuthContext>per connection to support agents that re-auth under multiple identities in one sessionowner_pubkeymatches; theowner_pubkeyfield is already persisted inConnectionManagerandAudioPeerin anticipation of this