Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
104 changes: 101 additions & 3 deletions crates/buzz-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<nostr::PublicKey>,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Option<nostr::PublicKey> {
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
Expand Down Expand Up @@ -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);
}
}
}
33 changes: 16 additions & 17 deletions crates/buzz-relay/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -237,20 +239,17 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, 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.
Expand Down