Repository navigation
Description
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
- Run a relay with
require_relay_membership = trueandallow_nip_oa_auth = true. - Generate an agent keypair; compute a NIP-OA auth tag with
buzz_sdk::nip_oa::compute_auth_tag. - Add the agent as a direct relay member (
run.sh add-member <agent> --role member). - As the agent, publish a kind:0 profile over
POST /eventswith thex-auth-tagheader set. - 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_onlybecomes 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 onmain@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
iroiro147 commentedon Aug 2, 2026
Shipped a fix: #4260
The
require_relay_membershipguard inbridge.rswas preventingextract_nip_oa_ownerfrom 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_ownerout of the conditional so it runs unconditionally whenenforce_relay_membershipreturnsOk(None). The behavioral matrix in the PR body shows the only changed cell is "closed relay + direct member + valid tag → owner materialized" (wasNone, nowSome(owner)).MarceloVasquez commentedon Aug 4, 2026
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_pubkeywas 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.
rmichelena commentedon Aug 11, 2026
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 againstmaintoday.1. The intended behavior is already written down — the doc comment contradicts the code.
crates/buzz-relay/src/config.rs:213-218:That is exactly the fix proposed here, described as if it were current behavior, while
bridge.rs:812-818gates 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:826callsmaterialize_nip_oa_ownerinside the event-submit path. The read endpoints (bridge.rs:964,bridge.rs:1405) callenforce_relay_membershipand discard the returned owner. So a smoke test that authenticates and queries returns 200 withagent_owner_pubkeystillNULL— a false green we walked into. Any repro must publish.3.
BUZZ_ALLOW_NIP_OA_AUTHfails silently if you write it the obvious way.config.rs:627-629accepts only"true"or"1"; the flag parsed immediately above it (BUZZ_MESH_DEMO_ECHO,:623-625) also acceptson.BUZZ_ALLOW_NIP_OA_AUTH=ontherefore evaluates tofalsewith no warning, and the default isfalse. It's indeploy/compose/.env.examplebut not the repo-root.env.example.4. Two consequences have no ops workaround at all, which I'd suggest raises the severity beyond
owner_onlyprovisioning:connection.rs:632derivesis_agentfromctx.agent_owner_pubkey.is_some(), so a NULL owner silently rate-limits the agent athuman_messages_per_min(:661) instead ofagent_standard_messages_per_min(:659). Every VPS-hosted agent on our relay was throttled to half quota without any log saying why.restricted: observer frame is not authorized for this agent owner.Both read
agent_owner_pubkeyfrom the session auth context assigned inhandlers/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-acplogsagent owner: <hex>at startup (crates/buzz-acp/src/lib.rs:1650-1652), but that comes fromresolve_agent_owner(&config)readingBUZZ_AUTH_TAGfrom the local environment. It prints identically with the relay-side mappingNULL. 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 itsx-auth-tag(e.g.buzz users set-profile) →add-member <agent>. Dropping direct membership routes the request through theViaOwnerbranch, 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:(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_ownerout of therequire_relay_membershipconditional, or havecheck_relay_membershipreturn the verified owner alongsideMember) matches what #4937 reports working. If no maintainer has claimed it, I'm glad to send the PR againstmainwith tests for both call sites; say the word and I'll leave it to you if you've already started.rmichelena commentedon Aug 11, 2026
PR up: #5581.
It hoists the extraction in
api/bridge.rs— the same change @iroiro147 proposed in #4260 — and does the same inhandlers/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 readagent_owner_pubkeyfrom 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_authchanges: 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-218describes 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.rshalf on top of yours if maintainers prefer that order.madisonicus commentedon Aug 12, 2026
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:mainrev96ae141, closed relay (require_relay_membership=true,allow_nip_oa_auth=true), with a hand-provisionedbuzz-acpseat on its own box. Same behavior exactly as described: the seat was a direct relay member, its kind:0 carried a valid NIP-OAauthtag, the event was accepted, andusers.agent_owner_pubkeystayedNULL. 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:
warnwhen a syntactically valid auth tag is discarded because membership was granted by the direct branch. Today the discard is invisible at every layer.agent_owner_pubkeyin 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.
feat(desktop): expose the relay-resolved NIP-OA owner on directory en…
fix(relay): trust and time-bound the NIP-OA owner before recording it
test(relay): cover NIP-OA owner materialization through both real paths
HolgerHatGarKeineNode commentedon Aug 15, 2026
Another surface of this bug: NIP-AM turn metrics (
kind:44200) are permanentlyundeliverable on a closed relay when the agent is a direct member.
Since #4950 landed in
desktop-v0.5.11,buzz-acpproduces per-turn usage forClaude adapters and calls
publish_agent_turn_metric, which submits akind:44200event tagged["p", <owner>]. The relay rejects every one of them:The check is
is_agent_owner(community, agent, owner)atcrates/buzz-relay/src/handlers/ingest.rs:2531-2543, andagent_owner_pubkeyis exactly the column this issue's
or_elsebranch never populates for adirect member.
Measured on 2026-08-15, relay on
main, harnessdesktop-v0.5.11, 10 agents,require_relay_membership = true, all agents direct relay members:owner-tools verify-attest→ attestation missing for all 10; ranattest→all accepted, present in
kind:0.POST /eventscarrying the same event shape.
BUZZ_AUTH_TAGso the harness presents the tag on submit(
crates/buzz-acp/src/lib.rs:1970-1975). Log confirmsowner resolved from BUZZ_AUTH_TAG: 0adf6747…on all 20 processes.Step 4 is the point of this comment: presenting a valid attestation on every
submit does not help, because
enforce_relay_membershipreturnsMembershipDecision::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-adminhas no subcommand to set the mapping (AddMember,RemoveMember,ProductFeedback,Deletions,ReconcileChannels,List),so there is no operational workaround.
publish_agent_turn_metriclogs only on thepool::metricstarget, whichRUST_LOG=buzz_acp=infodoes not enable —targets are not hierarchical. Without
pool::metricsinRUST_LOGthe metricsimply never appears and nothing is logged.
rmichelena commentedon Aug 15, 2026
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 asx-auth-tagon the submit, then akind:44200throughPOST /events.That 403 is your error verbatim —
agent-turn-metricp tag must be the registered owner of this agent.Why it clears
Your diagnosis of the chain is right.
is_agent_owneratingest.rs:2503-2511readsusers.agent_owner_pubkey, which is the columnenforce_relay_membership → Ok(None)never populated for a direct member.One detail worth having:
materialize_nip_oa_ownerruns atbridge.rs:869, andingest_eventat: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 thebuzz-adminsubcommand 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, andset_agent_owneris 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
ViaOwnerbranch 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
AddMemberfor 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:
BUZZ_AUTH_TAGset, which is your step 3.bridge_postattaches it asx-auth-tagon everyPOST /events.created_atbounds, whichverify_auth_tagpreviously parsed but never evaluated, so an expired credential was indistinguishable from a live one. An unbounded tag fromowner-tools attestis unaffected.On the silent failure
Your point about
pool::metricsnot being enabled byRUST_LOG=buzz_acp=infois 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.
fix(relay): record the NIP-OA owner for direct members on closed relays
fix(relay): trust and time-bound the NIP-OA owner before recording it
test(relay): cover NIP-OA owner materialization through both real paths
fix(relay): record the NIP-OA owner for direct members on closed relays