diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..791b400c6ec 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -809,13 +809,7 @@ async fn submit_event_authed( ) .await { - Ok(owner) => owner.or_else(|| { - if !state.config.require_relay_membership { - super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag) - } else { - None - } - }), + Ok(owner) => super::relay_members::resolve_nip_oa_owner(owner, &pubkey_bytes, auth_tag), Err(e) => { return SubmitOutcome::Err { status: e.0, diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b1..220faa63360 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -116,11 +116,13 @@ pub mod relay_members { /// its NIP-OA owner *is* — access is granted via delegation. /// /// On open relays (`require_relay_membership = false`), returns `Ok(None)` - /// immediately — no membership check is performed. Callers that need NIP-OA - /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// immediately — no membership check is performed. /// /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when - /// no NIP-OA tag is present/applicable (open relay without auth tag). + /// no NIP-OA tag is present/applicable (open relay without auth tag). `Ok(None)` + /// therefore means "admitted on its own", **not** "has no owner": callers that + /// record ownership must pass the result through [`resolve_nip_oa_owner`], which + /// recovers the owner from the presented tag in exactly those cases. pub async fn enforce_relay_membership( state: &AppState, community: CommunityId, @@ -166,6 +168,32 @@ pub mod relay_members { } } + /// Resolve the NIP-OA owner to materialize for a caller that has already + /// passed the membership gate. + /// + /// `gate_owner` is what [`enforce_relay_membership`] returned: `Some` only + /// when membership was granted *through* the owner. Every other admitted + /// caller — a direct relay member on a closed relay, or anyone on an open + /// relay — arrives here with `None`, and the `auth` tag they presented is + /// still cryptographically self-proving. Extract it rather than dropping it: + /// which branch granted *access* says nothing about whether the attestation + /// of *ownership* is valid. + /// + /// Gating this on `require_relay_membership` inverted the deployment + /// posture — the stricter relay was the only one that never recorded + /// ownership, so `owner_only` policies, observer-frame authorization and the + /// agent rate class all silently degraded for agents enrolled as members + /// (#4223, #4937). No feature flag applies: `allow_nip_oa_auth` governs + /// whether NIP-OA can *grant membership*, not whether a verified tag is + /// believed (see the flag's own doc comment in `config`). + pub fn resolve_nip_oa_owner( + gate_owner: Option, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Option { + gate_owner.or_else(|| extract_nip_oa_owner(pubkey_bytes, auth_tag_header)) + } + /// Persist a cryptographically verified NIP-OA agent→owner relationship. /// /// Both principals are ensured first because `agent_owner_pubkey` has a @@ -273,5 +301,75 @@ pub mod relay_members { assert_eq!(result, None); } + + /// Membership granted via the owner → that owner is kept as-is, without + /// re-verifying the tag. + #[test] + fn resolve_prefers_the_gate_owner() { + let gate_owner_keys = Keys::generate(); + let other_owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + + let tag_json = compute_auth_tag(&other_owner_keys, &agent_pubkey, "") + .expect("compute_auth_tag must succeed"); + + let result = resolve_nip_oa_owner( + Some(gate_owner_keys.public_key()), + &agent_pubkey.to_bytes(), + Some(&tag_json), + ); + + assert_eq!(result, Some(gate_owner_keys.public_key())); + } + + /// The regression this guards: a caller the gate admitted on its own — + /// a direct relay member on a closed relay — still has its verified + /// owner resolved, instead of the attestation being dropped. + #[test] + fn resolve_recovers_owner_for_a_direct_member() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute_auth_tag must succeed"); + + let result = resolve_nip_oa_owner(None, &agent_pubkey.to_bytes(), Some(&tag_json)); + + assert_eq!(result, Some(owner_keys.public_key())); + } + + /// A direct member that presents no tag stays ownerless — membership + /// alone never invents an owner. + #[test] + fn resolve_without_a_tag_returns_none() { + let agent_keys = Keys::generate(); + + let result = resolve_nip_oa_owner(None, &agent_keys.public_key().to_bytes(), None); + + assert_eq!(result, None); + } + + /// A tag that attests a *different* agent is not evidence about this + /// one: `verify_auth_tag` binds the attestation to the signing pubkey, + /// so an intercepted tag can't be replayed onto another agent. + #[test] + fn resolve_rejects_a_tag_minted_for_another_agent() { + let owner_keys = Keys::generate(); + let attested_agent_keys = Keys::generate(); + let impostor_keys = Keys::generate(); + + let tag_json = compute_auth_tag(&owner_keys, &attested_agent_keys.public_key(), "") + .expect("compute_auth_tag must succeed"); + + let result = resolve_nip_oa_owner( + None, + &impostor_keys.public_key().to_bytes(), + Some(&tag_json), + ); + + assert_eq!(result, None); + } } } diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e0..94002bd33e8 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2,9 +2,11 @@ //! //! Relay membership enforcement uses the shared //! [`crate::api::relay_members::enforce_relay_membership`] helper, which supports -//! NIP-OA owner-delegation fallback on closed relays. On open relays, the auth -//! handler calls [`crate::api::relay_members::extract_nip_oa_owner`] directly to -//! extract the owner pubkey for agent→owner backfill (observer frame auth). +//! NIP-OA owner-delegation fallback on closed relays. The owner recorded for +//! agent→owner backfill (observer frame auth, agent rate class) is then resolved +//! with [`crate::api::relay_members::resolve_nip_oa_owner`], which keeps the +//! delegated owner when membership came through one and otherwise verifies the +//! presented tag — a direct member's attestation is just as self-proving. //! //! For WebSocket auth, the NIP-OA `auth` tag is extracted from the signed AUTH //! event itself (the tag is integrity-protected by the event signature). @@ -237,20 +239,17 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - // Open relay NIP-OA backfill: extract owner for agent→owner DB mapping - // (needed for observer frame auth). Only runs on open relays — on closed - // relays, enforce_relay_membership already handles NIP-OA delegation. - // No feature flag needed: NIP-OA is cryptographically self-proving. - let nip_oa_owner = nip_oa_owner.or_else(|| { - if !state.config.require_relay_membership && auth_tag_json.is_some() { - crate::api::relay_members::extract_nip_oa_owner( - pubkey.as_bytes(), - auth_tag_json.as_deref(), - ) - } else { - None - } - }); + // NIP-OA backfill: resolve the owner for the agent→owner DB mapping + // (needed for observer frame auth and for the agent rate class). + // `enforce_relay_membership` reports an owner only when membership was + // granted *through* it, so a direct member's tag is resolved here — + // on closed relays too, matching the ban cascade above, which already + // trusts the same tag unconditionally. + let nip_oa_owner = crate::api::relay_members::resolve_nip_oa_owner( + nip_oa_owner, + pubkey.as_bytes(), + auth_tag_json.as_deref(), + ); // Stash NIP-OA owner on the auth context only after the shared // backfill confirms the first-write-wins relationship.