Skip to content

fix(relay): record the NIP-OA owner for direct members on closed relays - #5581

Open
rmichelena wants to merge 1 commit into
block:mainfrom
rmichelena:bumble/nip-oa-owner-closed-relay
Open

fix(relay): record the NIP-OA owner for direct members on closed relays#5581
rmichelena wants to merge 1 commit into
block:mainfrom
rmichelena:bumble/nip-oa-owner-closed-relay

Conversation

@rmichelena

Copy link
Copy Markdown

Fixes #4223. Also fixes the cluster reported in #4937 (rate class, owner context, backfill).

The bug

On a closed relay (require_relay_membership = true), an agent that is a direct relay member and presents a valid NIP-OA auth tag never gets users.agent_owner_pubkey recorded. The attestation is accepted for transport and then dropped.

check_relay_membership short-circuits on direct membership (api/mod.rs:77-79) and only consults the tag as a membership fallback for non-members, so enforce_relay_membership returns Ok(None) for a member. Both materialization sites then re-derived the owner behind the same conditional:

// api/bridge.rs (HTTP submit) and handlers/auth.rs (NIP-42 AUTH)
owner.or_else(|| {
    if !state.config.require_relay_membership {
        extract_nip_oa_owner(&pubkey_bytes, auth_tag)
    } else {
        None            // closed relay + direct member ⇒ owner never recorded
    }
})

The posture is inverted: the stricter deployment is the only one that never records ownership, and enrolling an agent as a member — the natural provisioning order — is what breaks it.

Why this is a correctness fix, not a policy change

Three places in the tree already say the tag should be believed unconditionally:

  • config.rs:213-218, on the flag itself: "extraction for agent→owner backfill happens unconditionally (the signature is cryptographically self-proving). This flag only controls whether NIP-OA can grant membership access on closed relays." That describes the behavior this PR implements, as though it were already true.
  • extract_nip_oa_owner's own doc comment: "cryptographically self-proving, so no feature flag is needed."
  • The ban cascade in the very same handler (handlers/auth.rs:137) and its git counterpart (api/git/transport.rs:257) extract the owner with no relay-openness condition — because a ban on a human must reach their agents. The same tag, in the same function, was trusted for denying access and discarded for recording ownership.

allow_nip_oa_auth is untouched: it still governs whether NIP-OA can grant membership, which is the only thing its doc comment claims.

Change

One shared, pure helper — relay_members::resolve_nip_oa_owner(gate_owner, pubkey, auth_tag) — keeps the delegated owner when membership came through one, and otherwise verifies the presented tag. Both materialization sites call it.

Doing it in one place rather than fixing each or_else is deliberate: the two call sites were identical copies, which is how they drifted from the intent in the first place, and a pure function is unit-testable without Postgres or Redis (the existing tests in this module cover only extract_nip_oa_owner).

I also corrected enforce_relay_membership's doc comment, since Ok(None) reading as "no owner" rather than "admitted on its own" is what the two call sites got wrong.

No behavior change on open relays (the or_else there already extracted), and none for callers that don't record ownership — media.rs, audio/handler.rs and the git transport keep using the membership gate exactly as before.

What this restores on closed relays

Consequence of a NULL owner Where
channel_add_policy: owner_only unenforceable — no owner to match policy check
Observer frames (kind 24200) refused: restricted: observer frame is not authorized for this agent owner handlers/event.rs
Agent rate-limited at human_messages_per_min instead of agent_standard_messages_per_min connection.rs:632, :659-661

The last two are the reason this needs the auth.rs half: both read agent_owner_pubkey from the session auth context, with no DB fallback, so a DB-level repair doesn't help a live connection and reconnecting re-runs the same gate.

Considered consequence

An agent admitted as a direct member can now enter the agent rate class by presenting a self-minted attestation (any keypair can attest any other). That authority is not new — the open-relay path and the ViaOwner path already accept exactly the same self-proving tag — and on a closed relay the actor must already be an admitted member. Flagging it explicitly rather than leaving it implicit: if maintainers want the agent rate class to require something stronger than a valid NIP-OA tag, that's a separate discussion about the rate class, not about which membership branch was taken.

Existing rows stay NULL until the agent next authenticates or submits; this is a fix-forward, not a migration.

Relation to #4260

#4260 (@iroiro147, Aug 2) fixes the same conditional in bridge.rs — the HTTP path — with the same reasoning. It's a correct diagnosis and it predates this PR. What it doesn't cover is handlers/auth.rs, which is where the WebSocket session context is built, so the rate class and observer-frame consequences survive it.

Happy to go either way: reduce this to the auth.rs half plus tests on top of #4260, or land this and close that one — whichever maintainers prefer. I'd rather not have two open PRs on one conditional.

Tests

Four new unit tests next to the existing extract_nip_oa_owner ones (cargo test -p buzz-relay --lib, no infrastructure needed):

  • delegated owner from the gate wins over the presented tag;
  • the regression: a caller the gate admitted on its own still has its verified owner resolved;
  • a member with no tag stays ownerless — membership alone never invents an owner;
  • a tag minted for a different agent is rejected, so an intercepted attestation can't be replayed onto another pubkey.

Also run: cargo fmt --check, cargo clippy -p buzz-relay --all-targets -- -D warnings.

Local run on this branch: cargo fmt --check clean, cargo clippy -p buzz-relay --all-targets -- -D warnings clean, cargo test -p buzz-relay --lib → 863 passed / 8 failed, where all 8 are the api::admin / api::media tests that need Postgres (Sqlx(PoolTimedOut)) and none touch this path — no Docker on this machine, so CI is the real check there.

On a closed relay, an agent that is a direct relay member never got
`users.agent_owner_pubkey` recorded, even with a valid NIP-OA auth tag.
`check_relay_membership` short-circuits on direct membership and consults
the tag only as a membership fallback for non-members, so the two
materialization sites re-derived the owner behind a
`!require_relay_membership` conditional and dropped it.

The posture was inverted: the stricter deployment was the only one that
never recorded ownership, and enrolling an agent as a member — the natural
provisioning order — is what broke it. Downstream, `owner_only` policies
had no owner to match, observer frames (kind 24200) were refused, and the
agent was rate-limited at the human tier, because `connection.rs` derives
`is_agent` from the session's `agent_owner_pubkey`.

Resolve the owner in one shared, pure helper used by both sites: keep the
delegated owner when membership came through one, otherwise verify the
presented tag. A direct member's attestation is just as self-proving — the
flag's own doc comment says extraction is unconditional, and the ban
cascade in the same handler already trusts the same tag with no relay
openness check. `allow_nip_oa_auth` still governs only whether NIP-OA can
grant membership.

Open relays are unaffected; callers that don't record ownership keep using
the membership gate unchanged.

Refs block#4223, block#4937, block#4260.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
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.

NIP-OA owner attestation silently discarded on closed relays when the agent is a direct relay member

1 participant