From d22d587004d8df507943de1d53f1d0eb680b6ddc Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 15:45:20 -0600 Subject: [PATCH] Enforce managed-agent revocation for direct members Verify signed NIP-OA owner evidence independently from the closed-relay delegation feature flag. Preserve and materialize that evidence for directly enrolled agents so AUTH binds subsequent REQ and EVENT revalidation to the durable PMA tombstone. Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-relay/src/api/mod.rs | 48 +++++++++++--------------- crates/buzz-relay/src/connection.rs | 38 +++++++++++++++++++- crates/buzz-relay/src/handlers/auth.rs | 14 ++++---- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 7c91c899f9..2137888d36 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -47,8 +47,10 @@ pub mod relay_members { pub enum MembershipDecision { /// Relay membership enforcement is disabled. OpenRelay, - /// Caller is directly present in `relay_members`. - Member, + /// Caller is directly present in `relay_members`. A verified owner is + /// retained for revocation rechecks and durable owner materialization; + /// it did not grant this membership. + Member(Option), /// Caller is admitted through a NIP-OA owner that is a relay member. ViaOwner(nostr::PublicKey), /// Caller is not admitted. @@ -56,13 +58,9 @@ pub mod relay_members { } fn verified_nip_oa_owner( - allow_nip_oa_auth: bool, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, ) -> Option { - if !allow_nip_oa_auth { - return None; - } let tag_json = auth_tag_header?; let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?; match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { @@ -91,11 +89,10 @@ pub mod relay_members { auth_tag_header: Option<&str>, ) -> Result { let pubkey_hex = hex::encode(pubkey_bytes); - let verified_owner = verified_nip_oa_owner( - !state.config.require_relay_membership || state.config.allow_nip_oa_auth, - pubkey_bytes, - auth_tag_header, - ); + // Verify owner evidence independently of whether NIP-OA may grant + // closed-relay membership. A direct member can still be a managed + // agent, and its proven owner is required to enforce a PMA tombstone. + let verified_owner = verified_nip_oa_owner(pubkey_bytes, auth_tag_header); let revoked = state .db .managed_agent_participation_is_revoked( @@ -121,7 +118,7 @@ pub mod relay_members { .await .map_err(|e| format!("relay membership check failed: {e}"))?; if is_member { - return Ok(MembershipDecision::Member); + return Ok(MembershipDecision::Member(verified_owner)); } if state.config.allow_nip_oa_auth { @@ -148,15 +145,15 @@ pub mod relay_members { /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. /// - /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but - /// its NIP-OA owner *is* — access is granted via delegation. + /// Returns `Ok(Some(owner_pubkey))` whenever the caller supplies verified + /// NIP-OA owner evidence, including when the caller is itself a direct + /// member. The feature flag controls only whether that evidence can grant + /// admission to a non-member on a closed relay. /// - /// On open relays (`require_relay_membership = false`), returns a verified - /// NIP-OA owner unconditionally for durable backfill. The feature flag only - /// controls whether delegation can grant access on a closed relay. + /// On open relays (`require_relay_membership = false`), owner evidence is + /// likewise returned unconditionally for durable backfill. /// - /// 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). + /// Returns `Ok(None)` only when no valid NIP-OA tag is present. pub async fn enforce_relay_membership( state: &AppState, community: CommunityId, @@ -164,7 +161,8 @@ pub mod relay_members { auth_tag_header: Option<&str>, ) -> Result, (StatusCode, Json)> { match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await { - Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), + Ok(MembershipDecision::OpenRelay) => Ok(None), + Ok(MembershipDecision::Member(owner)) => Ok(owner), Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), Ok(MembershipDecision::Denied) => Err(( StatusCode::FORBIDDEN, @@ -274,20 +272,16 @@ pub mod relay_members { use nostr::Keys; #[test] - fn closed_relay_disabled_nip_oa_does_not_supply_policy_owner_evidence() { + fn owner_evidence_is_verified_independently_of_membership_grant_policy() { let owner = Keys::generate(); let agent = Keys::generate(); let tag = compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); assert_eq!( - verified_nip_oa_owner(false, agent.public_key().as_bytes(), Some(&tag)), - None - ); - assert_eq!( - verified_nip_oa_owner(true, agent.public_key().as_bytes(), Some(&tag)), + verified_nip_oa_owner(agent.public_key().as_bytes(), Some(&tag)), Some(owner.public_key()) ); assert_eq!( - verified_nip_oa_owner(true, agent.public_key().as_bytes(), Some("invalid")), + verified_nip_oa_owner(agent.public_key().as_bytes(), Some("invalid")), None ); } diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 86e8eda06c..d7ef3b779b 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -591,13 +591,20 @@ fn request_rejection_message(sub_id: Option<&str>, reason: &str) -> String { } } +fn requires_participation_revalidation(msg: &ClientMessage) -> bool { + matches!( + msg, + ClientMessage::Req { .. } | ClientMessage::Count { .. } | ClientMessage::Event(_) + ) +} + async fn enforce_ws_admission( msg: &ClientMessage, conn: &ConnectionState, state: &AppState, ) -> bool { let is_event = matches!(msg, ClientMessage::Event(_)); - if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) { + if !requires_participation_revalidation(msg) { return true; } @@ -808,6 +815,35 @@ mod tests { .collect() } + #[test] + fn managed_agent_revocation_revalidates_req_and_all_event_storage_classes() { + use nostr::{EventBuilder, Filter, Keys, Kind}; + + let signed = |kind| { + EventBuilder::new(kind, "") + .sign_with_keys(&Keys::generate()) + .expect("sign event") + }; + let cases = [ + ClientMessage::Req { + sub_id: "history".into(), + filters: vec![Filter::new()], + }, + ClientMessage::Event(signed(Kind::TextNote)), + ClientMessage::Event(signed(Kind::Custom(20_001))), + ]; + + for case in &cases { + assert!( + requires_participation_revalidation(case), + "REQ, persistent EVENT, and ephemeral EVENT must all revalidate durable revocation" + ); + } + assert!(!requires_participation_revalidation(&ClientMessage::Close( + "history".into() + ))); + } + #[test] fn req_rejections_are_subscription_scoped() { let reason = "rate-limited: too many concurrent requests"; diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 9cbb31c2df..6e2dfced44 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2,9 +2,10 @@ //! //! Relay membership enforcement uses the shared //! [`crate::api::relay_members::enforce_relay_membership`] helper. NIP-OA -//! owner evidence is returned by the shared gate on open relays unconditionally -//! and on closed relays when delegation auth is enabled, then durably -//! materialized for later transport-neutral revocation checks. +//! owner evidence is returned by the shared gate whenever it verifies, while +//! the feature flag controls only whether that evidence may grant closed-relay +//! membership. The relationship is then durably materialized for later +//! transport-neutral revocation checks. //! //! 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,9 +238,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - // The shared gate returns a verified owner only when NIP-OA is - // enabled, including on open relays. Preserve that feature gate for - // ownership materialization. + // A verified owner is returned for both direct members and + // delegated admissions. The feature flag controls only whether the + // latter can grant access; owner evidence remains revocation + // authority in either case. // Stash NIP-OA owner on the auth context only after the shared // backfill confirms the first-write-wins relationship. if let Some(owner) = nip_oa_owner {