Skip to content

feat(relay): implement NIP-AA agent authentication via NIP-OA credentials#468

Closed
tlongwell-block wants to merge 33 commits into
mainfrom
feat/nip-aa-implementation
Closed

feat(relay): implement NIP-AA agent authentication via NIP-OA credentials#468
tlongwell-block wants to merge 33 commits into
mainfrom
feat/nip-aa-implementation

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

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 lines

Six-step verification algorithm:

  1. Standard NIP-42 checks: kind=22242, valid id/sig, relay tag, challenge match, ±120s freshness
  2. Direct membership short-circuit — if the agent pubkey is already a member, skip NIP-AA entirely
  3. Extract exactly one auth tag — zero means not an agent; more than one is rejected
  4. Cryptographic NIP-OA credential verification with created_at condition evaluation
  5. Owner pubkey active-membership check against the relay's member store
  6. Grant virtual membership — no DB write; owner pubkey retained in session state

NIP-42 auth handler (crates/sprout-relay/src/handlers/auth.rs)

  • Integrated verify_nip_aa() into the main WebSocket auth flow
  • Failed re-auth preserves the existing authenticated identity (per NIP-AA §6)
  • Malformed AUTH frames now close the WebSocket connection (per spec)

Audio WebSocket (crates/sprout-relay/src/audio/handler.rs, audio/room.rs)

  • NIP-AA verification wired into the audio WebSocket auth path
  • AudioPeer gains an owner_pubkey field for owner-scoped session tracking

Connection state (crates/sprout-relay/src/connection.rs, state.rs)

  • ConnectionManager stores owner_pubkey per connection
  • Auth state machine updated to handle malformed-AUTH → close transition

Scopes (crates/sprout-auth/src/scope.rs)

  • New virtual member scope set: full read/write, AdminChannels and AdminUsers excluded

Design Decisions

Virtual membership, no persistence. Agent access lives entirely in session state. There is no agents table, 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:

  • All six verification steps, including boundary conditions (freshness window edges, tag count variants, credential signature tampering)
  • Scope intersection logic for token + NIP-AA combinations
  • Virtual member scope set correctness (admin scopes excluded)
  • Failed re-auth non-destructive behavior
  • Malformed AUTH → close transition

Not covered (deferred):

  • End-to-end integration tests require a running relay instance and a NIP-42-capable test client. Infrastructure is in place; tests are not yet written.
  • Owner-scoped session termination on member removal is not wired, so no tests for that path.

Related PRs

PR Description
#398 NIP-OA: Owner Attestation for agent key provenance (spec)
#406 feat(nip-oa): Owner Attestation — implementation
#448 feat: relay membership with NIP-43 compliance
#465 NIP-AA: Agent Authentication via NIP-OA Credentials (spec)

Future Work

The following are explicitly deferred and documented in code:

  • Per-event kind= enforcement — the NIP-AA spec marks this optional; not implemented
  • Multi-pubkey per connection — replace the single-pubkey model with a HashMap<PublicKey, AuthContext> per connection to support agents that re-auth under multiple identities in one session
  • Owner-scoped session termination — when a member is removed, terminate all active agent sessions whose owner_pubkey matches; the owner_pubkey field is already persisted in ConnectionManager and AudioPeer in anticipation of this
  • E2E integration tests — need a relay harness + NIP-42 test client; unit coverage is complete

… 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
…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
@tlongwell-block tlongwell-block deleted the feat/nip-aa-implementation branch May 22, 2026 19:06
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