Skip to content

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

Open

Description

@jdrolls

Describe the bug

On a closed relay (require_relay_membership = true, allow_nip_oa_auth = true), submitting an event with a valid x-auth-tag NIP-OA owner attestation does not materialize users.agent_owner_pubkey if the signing agent is already a direct relay member. The attestation is accepted for transport and then silently dropped.

The behavior is inverted relative to open relays: an open relay extracts the owner from the auth tag regardless of membership, while a closed relay — the stricter deployment — only materializes the owner when the agent is not a direct member.

Where (verified against main @ ac4fa13, 2026-08-01):

crates/buzz-relay/src/api/bridge.rs:812-818:

Ok(owner) => owner.or_else(|| {
    if !state.config.require_relay_membership {
        super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag)
    } else {
        None                // closed relay + direct member => owner never extracted
    }
}),

The interaction is with check_relay_membership (crates/buzz-relay/src/api/mod.rs:61): the direct-membership fast path returns MembershipDecision::Member (mod.rs:78), which maps to Ok(None) — no owner. Only the ViaOwner branch (mod.rs:100, mapped at mod.rs:131-132) carries the owner pubkey forward. So the owner is materialized exactly when membership was granted via the owner, and dropped when the agent satisfied membership directly — even though the attestation is identical and valid in both cases.

Steps to reproduce

  1. Run a relay with require_relay_membership = true and allow_nip_oa_auth = true.
  2. Generate an agent keypair; compute a NIP-OA auth tag with buzz_sdk::nip_oa::compute_auth_tag.
  3. Add the agent as a direct relay member (run.sh add-member <agent> --role member).
  4. As the agent, publish a kind:0 profile over POST /events with the x-auth-tag header set.
  5. Query the DB:
SELECT encode(agent_owner_pubkey, 'hex') FROM users WHERE pubkey = decode('<agent>', 'hex');

Expected behavior

agent_owner_pubkey is set to the attested owner. A valid attestation should be honored regardless of which membership branch granted access.

Actual behavior

agent_owner_pubkey is NULL. The event is accepted ({"accepted": true}), so there is no signal that the attestation was ignored.

Removing the direct relay membership and republishing the identical event materializes the owner correctly — which is what makes the cause unambiguous.

Impact

  • channel_add_policy: owner_only becomes unenforceable in the intended direction: with no owner on record, the policy's owner check has nothing to match. Provisioning an agent in the natural order (admit to relay → publish profile) yields an agent whose ownership never registers.
  • Owner-scoped lookups (buzz users get --name <n> --owner <hex>) return nothing.
  • Silent: nothing in the response or logs indicates the tag was discarded.

Suggested fix

Extract and materialize the owner whenever a syntactically valid auth tag is present and verifies against the signing pubkey, independent of which membership branch granted access. Concretely: either have check_relay_membership return the verified owner alongside Member, or hoist the extract_nip_oa_owner call out of the require_relay_membership conditional in bridge.rs.

Happy to work on a PR for this if a maintainer confirms the intended direction.

Version and platform

  • Buzz version: self-hosted relay, ghcr.io/block/buzz:main @ sha256:13ddb4c12f4c0b8147cece717a57013a45fdb1610bd072d7e383ac79a070ddd9; behavior re-verified in source on main @ ac4fa13 (2026-08-01)
  • OS: Ubuntu 24.04 (relay host), NIP-11 supported_nips [1,2,10,11,16,17,23,25,29,33,38,42,50,56,43]

Logs / additional context

Related issues (searched open and closed — no duplicate found): closest adjacent are the closed-relay bootstrap cluster #2969 / #3888 (membership timing/lockout, not attestation handling). Note the interaction: the bootstrap ordering those issues push operators toward (admit first, then publish) is exactly the ordering that triggers this bug.

Activity

  1. iroiro147 commented on Aug 2, 2026

    @iroiro147
    Contributor

    Shipped a fix: #4260

    The require_relay_membership guard in bridge.rs was preventing extract_nip_oa_owner from running on closed relays when the agent was a direct member — exactly the case this issue identifies. The NIP-OA auth tag is cryptographically self-proving, so the guard was excluding the case where it matters most.

    The fix hoists extract_nip_oa_owner out of the conditional so it runs unconditionally when enforce_relay_membership returns Ok(None). The behavioral matrix in the PR body shows the only changed cell is "closed relay + direct member + valid tag → owner materialized" (was None, now Some(owner)).

  2. MarceloVasquez commented on Aug 4, 2026

    @MarceloVasquez

    Independent confirmation from a real self-hosted deployment using Hermes Agent’s native Buzz gateway.

    Our setup uses a closed relay with NIP-OA enabled and a dedicated agent identity. Following the natural provisioning order — admitting the identity as a direct relay member, then publishing its NIP-OA-attested kind:0 profile — produced the exact behavior described here: the event was accepted, but agent_owner_pubkey was not materialized.

    Removing the direct relay membership and republishing the same attested profile fixed the ownership classification immediately. This was not cosmetic: until then, the identity appeared as a member/guest rather than a properly owner-attested agent.

    Happy to test an official relay build containing #4260 against this deployment.

  3. rmichelena commented on Aug 11, 2026

    @rmichelena

    Independent confirmation on a second self-hosted relay (require_relay_membership = true, allow_nip_oa_auth = true), plus five things that cost us time and aren't in this issue or #4937 yet. All line references re-checked against main today.

    1. The intended behavior is already written down — the doc comment contradicts the code. crates/buzz-relay/src/config.rs:213-218:

    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 is exactly the fix proposed here, described as if it were current behavior, while bridge.rs:812-818 gates extraction on !require_relay_membership. Whichever way it's resolved, one of the two should move.

    2. Materialization happens only on a write. bridge.rs:826 calls materialize_nip_oa_owner inside the event-submit path. The read endpoints (bridge.rs:964, bridge.rs:1405) call enforce_relay_membership and discard the returned owner. So a smoke test that authenticates and queries returns 200 with agent_owner_pubkey still NULL — a false green we walked into. Any repro must publish.

    3. BUZZ_ALLOW_NIP_OA_AUTH fails silently if you write it the obvious way. config.rs:627-629 accepts only "true" or "1"; the flag parsed immediately above it (BUZZ_MESH_DEMO_ECHO, :623-625) also accepts on. BUZZ_ALLOW_NIP_OA_AUTH=on therefore evaluates to false with no warning, and the default is false. It's in deploy/compose/.env.example but not the repo-root .env.example.

    4. Two consequences have no ops workaround at all, which I'd suggest raises the severity beyond owner_only provisioning:

    • Rate class: connection.rs:632 derives is_agent from ctx.agent_owner_pubkey.is_some(), so a NULL owner silently rate-limits the agent at human_messages_per_min (:661) instead of agent_standard_messages_per_min (:659). Every VPS-hosted agent on our relay was throttled to half quota without any log saying why.
    • Observer frames (kind 24200, "View Activity"): rejected with restricted: observer frame is not authorized for this agent owner.

    Both read agent_owner_pubkey from the session auth context assigned in handlers/auth.rs, with no DB fallback — so a DB-level repair doesn't help a live connection, and reconnecting doesn't either (the same gate runs again at AUTH). Only the upstream fix closes them.

    5. A false green to warn people about: buzz-acp logs agent owner: <hex> at startup (crates/buzz-acp/src/lib.rs:1650-1652), but that comes from resolve_agent_owner(&config) reading BUZZ_AUTH_TAG from the local environment. It prints identically with the relay-side mapping NULL. It is not evidence of anything relay-side.

    Workaround for anyone stuck today (no code changes): remove-member <agent> → have the agent perform one authenticated write with its x-auth-tag (e.g. buzz users set-profile) → add-member <agent>. Dropping direct membership routes the request through the ViaOwner branch, which materializes; the mapping is first-write-wins, so re-adding doesn't clobber it. For legacy rows without touching membership, the relay's own statement is idempotent and safe:

    UPDATE users SET agent_owner_pubkey = $1
    WHERE community_id = $2 AND pubkey = $3 AND agent_owner_pubkey IS NULL;

    (That skips signature verification, so the operator vouches for the mapping.) Neither restores the session-context consequences in point 4.

    @jdrolls — your suggested fix (hoist extract_nip_oa_owner out of the require_relay_membership conditional, or have check_relay_membership return the verified owner alongside Member) matches what #4937 reports working. If no maintainer has claimed it, I'm glad to send the PR against main with tests for both call sites; say the word and I'll leave it to you if you've already started.

  4. rmichelena commented on Aug 11, 2026

    @rmichelena

    PR up: #5581.

    It hoists the extraction in api/bridge.rs — the same change @iroiro147 proposed in #4260 — and does the same in handlers/auth.rs, which is the piece that matters for the two consequences with no operator workaround: the agent rate class and observer-frame authorization both read agent_owner_pubkey from the session context built there, with no DB fallback.

    Both sites now call one shared pure helper (resolve_nip_oa_owner), so the HTTP and WS paths can't drift apart again, and the pure shape means it's unit-testable without Postgres — four tests added, including that a tag minted for a different agent is rejected.

    Nothing about allow_nip_oa_auth changes: it still governs only whether NIP-OA can grant membership. The strongest argument for the change turned out to be in the tree already — config.rs:213-218 describes unconditional extraction as if it were current behavior, and the ban cascade in the same handler (auth.rs:137) already trusts the identical tag with no relay-openness check.

    @iroiro147 — flagged the overlap on #4260; happy to reduce mine to the auth.rs half on top of yours if maintainers prefer that order.

  5. madisonicus commented on Aug 12, 2026

    @madisonicus

    Confirming this independently on a second deployment, and adding the operational cost, which I think is worse than the issue currently conveys.

    Reproduced on relay ghcr.io/block/buzz:main rev 96ae141, closed relay (require_relay_membership=true, allow_nip_oa_auth=true), with a hand-provisioned buzz-acp seat on its own box. Same behavior exactly as described: the seat was a direct relay member, its kind:0 carried a valid NIP-OA auth tag, the event was accepted, and users.agent_owner_pubkey stayed NULL. Removing the direct membership and republishing the identical event materialized the owner immediately.

    The operational cost. The natural provisioning order (i.e., admit the agent to the relay, then have it publish its profile) produces an agent that is permanently unreachable, and the only cure is to remove the agent from the relay and add it back. That ordering is not discoverable: adding the agent as a member is the obvious first step, every subsequent step succeeds, and nothing anywhere reports that the attestation was dropped. We spent a debugging session on this before finding the short-circuit in the source, because every server-side surface we could measure (membership, roles, channel rows, presence, accepted events) was green and correct.

    Two things that might be cheap and would have collapsed the diagnosis to minutes:

    • Log at warn when a syntactically valid auth tag is discarded because membership was granted by the direct branch. Today the discard is invisible at every layer.
    • Surface agent_owner_pubkey in whatever the operator can already query about an agent, so "the attestation did not land" is answerable without psql.

    Happy to test a patch on this deployment if that is useful. The reproduction is standing and takes about a minute to re-run.

  6. HolgerHatGarKeineNode commented on Aug 15, 2026

    @HolgerHatGarKeineNode

    Another surface of this bug: NIP-AM turn metrics (kind:44200) are permanently
    undeliverable
    on a closed relay when the agent is a direct member.

    Since #4950 landed in desktop-v0.5.11, buzz-acp produces per-turn usage for
    Claude adapters and calls publish_agent_turn_metric, which submits a
    kind:44200 event tagged ["p", <owner>]. The relay rejects every one of them:

    403 {"error":"restricted: agent-turn-metric `p` tag must be the registered owner of this agent"}
    

    The check is is_agent_owner(community, agent, owner) at
    crates/buzz-relay/src/handlers/ingest.rs:2531-2543, and agent_owner_pubkey
    is exactly the column this issue's or_else branch never populates for a
    direct member.

    Measured on 2026-08-15, relay on main, harness desktop-v0.5.11, 10 agents,
    require_relay_membership = true, all agents direct relay members:

    1. owner-tools verify-attest → attestation missing for all 10; ran attest →
      all accepted, present in kind:0.
    2. Still 403 — on both tenants, reproduced with a hand-built POST /events
      carrying the same event shape.
    3. Set BUZZ_AUTH_TAG so the harness presents the tag on submit
      (crates/buzz-acp/src/lib.rs:1970-1975). Log confirms
      owner resolved from BUZZ_AUTH_TAG: 0adf6747… on all 20 processes.
    4. Still 403.

    Step 4 is the point of this comment: presenting a valid attestation on every
    submit does not help, because enforce_relay_membership returns
    MembershipDecision::Member → Ok(None) for a direct member
    (api/mod.rs:132-133) and the fallback is gated on
    !require_relay_membership.

    Two notes for anyone hitting this:

    • buzz-admin has no subcommand to set the mapping (AddMember,
      RemoveMember, ProductFeedback, Deletions, ReconcileChannels, List),
      so there is no operational workaround.
    • The failure is silent by default. publish_agent_turn_metric logs only on the
      pool::metrics target, which RUST_LOG=buzz_acp=info does not enable —
      targets are not hierarchical. Without pool::metrics in RUST_LOG the metric
      simply never appears and nothing is logged.
  7. rmichelena commented on Aug 15, 2026

    @rmichelena

    Confirmed, and #5581 fixes it — but there is a condition in that PR you'll want to check against your setup before assuming it unblocks you.

    Reproduced

    I ran your scenario against a real relay + Postgres: closed relay (require_relay_membership = true), agent a direct relay member, valid attestation presented as x-auth-tag on the submit, then a kind:44200 through POST /events.

    without the fix:   403 Forbidden   (4/4 runs)
    with the fix:      accepted
    

    That 403 is your error verbatim — agent-turn-metric p tag must be the registered owner of this agent.

    Why it clears

    Your diagnosis of the chain is right. is_agent_owner at ingest.rs:2503-2511 reads users.agent_owner_pubkey, which is the column enforce_relay_membership → Ok(None) never populated for a direct member.

    One detail worth having: materialize_nip_oa_owner runs at bridge.rs:869, and ingest_event at :879. Same request. So the first metric that carries a valid tag writes the mapping and then passes the gate — you don't need a separate priming step, and you don't need the buzz-admin subcommand you correctly found missing. The WebSocket path materializes at NIP-42 AUTH instead, so an agent that connects over WS is covered before it ever submits.

    The condition — please check this

    Review of #5581 turned up that resolving an owner purely from a self-presented attestation was too permissive: any direct member could mint a throwaway keypair, attest itself, and have that key recorded. It is not inert — it selects the agent rate class in connection.rs, and set_agent_owner is first-write-wins, so a bogus mapping is permanent and the real owner is refused afterwards.

    So the merged behaviour requires the claimed owner to also be a relay member on a closed relay, mirroring what the ViaOwner branch already demanded.

    Your report says all ten agents are direct relay members. It doesn't say whether the owner key is one. If it isn't, you will still get the 403 after this lands, and the fix is one AddMember for the owner rather than anything about the agents. Worth confirming before you conclude the PR didn't work for you.

    Two smaller things that also have to hold, both of which you already satisfied:

    • The tag must be presented on the submit itself — BUZZ_AUTH_TAG set, which is your step 3. bridge_post attaches it as x-auth-tag on every POST /events.
    • The attestation must be inside its time window. fix(relay): record the NIP-OA owner for direct members on closed relays #5581 also started enforcing the tag's created_at bounds, which verify_auth_tag previously parsed but never evaluated, so an expired credential was indistinguishable from a live one. An unbounded tag from owner-tools attest is unaffected.

    On the silent failure

    Your point about pool::metrics not being enabled by RUST_LOG=buzz_acp=info is a good catch and independent of this bug — the metric vanishing with nothing logged is what makes this expensive to diagnose. That seems worth its own issue against the harness regardless of what happens here; happy to file it if you'd rather not.

    Thanks for the measured repro — steps 3 and 4 in particular are what make it obvious this isn't an attestation problem.

  8. added a commit that references this issue on Sep 13, 2026
    2a02440
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions