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

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

Conversation

@rmichelena

@rmichelena rmichelena commented Aug 11, 2026

Copy link
Copy Markdown

The bug

On a closed relay (require_relay_membership = true), an agent that is itself a relay member never gets its NIP-OA owner recorded, however many valid attestations it presents.

enforce_relay_membership returns Ok(None) the moment direct membership is established, and the only owner-extraction fallback at either call site is gated on !require_relay_membership — off exactly where it is needed:

// crates/buzz-relay/src/api/bridge.rs:933 on main (4cd82f513)
Ok(owner) => owner.or_else(|| {
    if !state.config.require_relay_membership {
        super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag, signed_auth_created_at)
    } else {
        None
    }
}),

The comment above the WebSocket twin (handlers/auth.rs:245) states the premise out loud — "on closed relays, enforce_relay_membership already handles NIP-OA delegation" — and that is the mistake: it handles delegation, which a direct member never reaches.

With agent_owner_pubkey left NULL the relay cannot tell the agent from a human. owner_only policies have nothing to match, observer frames are refused, and connection.rs rate-classes the connection as human. Issues: #4223, #4937.

The fix

One shared helper, relay_members::resolve_nip_oa_owner, consulted at both call sites after admission:

Caller Behaviour
admitted through delegation keep the owner admission already proved is a member
open relay unchanged from today
direct member, closed relay resolve the attestation — and require the owner to be a relay member

That last requirement is load-bearing, not decoration. materialize_nip_oa_owner is first-write-wins and the recorded owner selects the agent rate class, so without it any member could attest itself with a throwaway key, raise its own rate budget, and bind the mapping permanently against the real owner. This is the trust-boundary blocker @ravarora2 raised on the earlier revision.

allow_nip_oa_auth deliberately does not gate this. Per its own doc comment the flag governs whether NIP-OA may grant membership on a closed relay; nothing here grants access — the caller is already in.

What changed since the last revision

#7004 landed the time-bound work this branch used to carry, under different names (VerifiedBridgeAuth.signed_created_at, verify_auth_tag_for_auth_event). That was blocking review comment (1). All of it is now out of the diff — the branch builds on those primitives instead of duplicating them. 13 files / +1375 → 3 files.

The ci.yml patch is gone too. main now discovers PostgreSQL-backed tests structurally, so these live in postgres_tests modules with #[ignore = "requires PostgreSQL and Redis"] and the existing postgres lane picks them up. scripts/check-postgres-test-discovery.py crates/buzz-relay/src validates, and scripts/postgres-test-packages.sh lists buzz-relay.

@ravarora2's two outer-NIP-98 tests are carried forward with attribution.

Verification

Nine tests against real PostgreSQL and Redis, covering both production paths — submit_event_authed, the full POST /events router, and handle_auth on the WebSocket side. 9 passed, 0 failed.

Both halves of the fix were proven by breaking them:

Mutation Result
restore main's gate (never resolve on a closed relay) 5 of 9 fail — both records_owner_for_direct_member, the WebSocket records_owner_and_sets_auth_context, and the inside-window controls of the two expiry tests
drop the owner-must-be-a-member requirement 2 of 9 failhttp_refuses_an_owner_that_is_not_a_relay_member and its WebSocket twin

Stated plainly: the four tests that survive mutation A pass vacuously against the old code, which never materialized anything on a closed relay. The positive cases are what discriminate, which is why each expiry test carries an inside-window control.

cargo fmt --check -p buzz-relay exit 0 · cargo clippy -p buzz-relay --all-targets -- -D warnings exit 0 · cargo test -p buzz-relay --lib 1039 passed / 1 failed, the failure being api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo (a 504 in a module this branch does not touch) which passes in isolation on the same commit.

@rmichelena
rmichelena requested a review from a team as a code owner August 11, 2026 17:02
rmichelena added a commit to rmichelena/buzz that referenced this pull request Aug 14, 2026
…tries

An agent whose `respond_to` is `owner-only` — the harness default, so the
common case — is excluded by `relayAgentIsSharedWithUser` for everyone,
including the person the policy names as the only allowed sender. The
eligibility layer cannot fix that on its own: neither `RelayAgentInfo` nor
the TS `RelayAgent` carries an owner, so it has no way to ask whether the
viewer owns the agent.

`relay_enrich` already resolves each agent's NIP-OA owner from its kind:0
— it has to, to verify kind:30177 authorship before a record may seed or
override a directory entry. The owner was simply not exposed. This carries
it through to the frontend so an owner-aware admission branch (block#5484) has
the data without a second kind:0 round trip.

No eligibility behaviour changes here. `owner_pubkey` is populated and
otherwise unread, so the directory stays the only thing this PR alters.

`None`/`null` means unresolved, not unowned. On a closed relay the NIP-OA
owner frequently never materializes on the agent's kind:0 (block#4223, relay-side
fix in block#5581), and headless agents are exactly the population that runs
there — so a consumer must treat absence as "unknown" rather than as a
negative answer.

A test pins that the field serializes as `owner_pubkey`, not `ownerPubkey`:
the Tauri payload contract is snake_case and `fromRawRelayAgent` does the
camelCase mapping, so a rename would silently land `undefined` on the TS
side with nothing failing.

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

ravarora2 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🤖 Review verdict: request changes

The core fix is useful and should remain: a directly enrolled agent must still have its valid owner relationship recorded. Both the HTTP and WebSocket paths need this repair. Before merge, please address the following.

Blockers

  1. Enforce the signed credential time bounds before trusting or persisting the owner.

    buzz_sdk::nip_oa::verify_auth_tag currently validates the tag structure and signature, but does not evaluate created_at<... or created_at>.... The relay must evaluate those clauses against the timestamp of the signed NIP-42 or NIP-98 authentication event before it:

    • writes users.agent_owner_pubkey;
    • sets AuthContext.agent_owner_pubkey;
    • grants owner-derived authorization;
    • selects the higher agent message quota.

    Please test a valid window, an expired credential, a not-yet-valid credential, and exact equality at both strict boundaries.

  2. Preserve the closed-relay owner trust boundary.

    A direct member can currently present a tag signed by a fresh second key that is not a relay member. The PR then creates that key as the owner and places it in the trusted WebSocket context, even with BUZZ_ALLOW_NIP_OA_AUTH=false. This differs from the existing ViaOwner path, which requires an active member owner, and it can change the default quota from the human 60/min tier to the agent 120/min tier.

    On a closed relay, require the claimed owner to satisfy the relay trust policy before using it for owner-only actions, live-session authorization, or rate classification. If unconditional cryptographic relationship backfill is intentional, separate relationship metadata from trusted authorization so an untrusted/non-member owner does not gain those effects.

  3. Add regression tests through both production paths.

    The four new tests call only resolve_nip_oa_owner. Restoring both old buggy call-site conditionals leaves every added test green, so the tests do not protect the actual regression.

    Please add tests that execute:

    • closed relay + direct member + valid tag through HTTP /events -> expected owner stored;
    • closed relay + direct member + valid tag through NIP-42 -> expected owner stored and live auth context set;
    • missing or invalid tag -> owner remains unset;
    • expired or not-yet-valid tag -> no materialization and no agent session classification;
    • non-member-owner and configuration combinations -> behavior matches the documented closed-relay policy.

    Acceptance criterion: reverting either HTTP or WebSocket production fix must fail its corresponding test.

Non-blockers

  • Update crates/buzz-auth/src/lib.rs:75-79, which still says direct members have no owner context.
  • Update crates/buzz-relay/src/api/mod.rs:149-155, which still describes owner extraction as open-relay-only.
  • Document that existing NULL owner rows are repaired only when the agent next authenticates or submits an event; this is fix-forward, not a migration.

Verification evidence

  • The intended HTTP behavior was reproduced locally: a legitimate direct-member tag was accepted and the expected owner was stored.
  • A live closed-relay NIP-42 probe accepted created_at<1 from a non-member claimed owner with BUZZ_ALLOW_NIP_OA_AUTH=false, then persisted that owner. This confirms blockers 1 and 2 through the real WebSocket path.
  • Full cargo test -p buzz-relay: 869 passed, 2 failed, 40 ignored. Independent parent/base runs reproduced the failures in unchanged tests, so no package-test regression was attributable to this PR.
  • cargo fmt --check and cargo clippy -p buzz-relay --all-targets -- -D warnings passed in independent review.

Review scope: commit 9bb913f25cf37b4aede9525ac90d3af31b0c8a4e. This is the unique ID of the exact PR code snapshot that was reviewed and tested. If new commits are pushed, the PR gets a new ID and the changed code must be re-reviewed.

@ravarora2 ravarora2 added the triage-ready Appropriate for agentic review label Aug 14, 2026
@rmichelena

Copy link
Copy Markdown
Author

Accepting all three blockers. I verified each against 9bb913f2 rather than reasoning about them, and two of the checks turned up something that changes what the fix has to look like.

Blocker 3 — confirmed empirically, and it's worse than stated

I reverted both production call sites (bridge.rs and auth.rs), keeping the helper and the four tests:

cargo test -p buzz-relay relay_members
7 passed; 0 failed

Green with the bug restored. Your acceptance criterion is the right one.

But the tests wouldn't have caught it even if they had been written against the call sites, because no CI job executes api::relay_members::tests. I checked every path:

  • just test-unit enumerates packages explicitly and buzz-relay is not among them.
  • scripts/run-tests.sh integration runs cargo test --test '*', which only matches integration-test targets; crates/buzz-relay has no tests/ directory.
  • ci.yml selects buzz-relay tests only through two explicit nextest filters — test(/api::invites::tests/) and test(/handlers::relay_admin::tests/).

api::relay_members::tests matches none of them, so those four tests and the four that predate this PR have never run in CI. The justfile already warns about this class of gap in a comment: "nothing in CI runs cargo test --workspace — workspace membership alone buys clippy/check, not a single executed test."

So the fix is to follow the handlers::relay_admin::tests precedent — #[ignore]d Postgres-backed tests exercising the real HTTP and NIP-42 paths, plus a CI step selecting them explicitly. Adding tests to the existing module without the CI wiring would satisfy the letter of the criterion and still never run.

Blocker 1 — correct, and the evaluator already exists in-tree

validate_conditions is purely syntactic: validate_clause checks that created_at<N is a canonical decimal in range and never compares N to anything. Probed directly:

created_at<1             (expired since 1970)     -> accepted, owner materialized
created_at>4294967294    (not valid until 2106)   -> accepted, owner materialized

Two things worth putting on the record.

First, this is not new in this PR. check_relay_membership calls the same verify_auth_tag for the ViaOwner branch, which I don't touch — so on main today an expired credential can already grant relay membership on a closed relay, which is a strictly stronger effect than recording an owner. This PR widens the reach of an existing gap rather than creating one. That's not a defence of shipping it as-is; it's an argument that the fix belongs where both paths pick it up.

Second, the semantics you asked me to pin are already pinned in-tree. enforce_request_auth_time_bounds in handlers/identity_archive.rs:328 evaluates exactly these clauses, and its tests fix both boundaries as strict:

assert!(enforce_request_auth_time_bounds(&auth, 150).is_ok());
assert!(enforce_request_auth_time_bounds(&auth, 100).is_err());  // created_at>100
assert!(enforce_request_auth_time_bounds(&auth, 200).is_err());  // created_at<200

So the archive handler already enforces time bounds on a NIP-OA tag while the ownership path does not. I'd rather hoist that function into shared code and apply it in both places than write a second evaluator that could drift from it — tell me if you'd prefer it scoped differently.

Blocker 2 — correct, and it's this PR's doing

I traced the consequences and they're a little wider than described:

  • materialize_nip_oa_owner calls ensure_user for the owner, so an arbitrary non-member key gets a user row created on a closed relay.
  • connection.rs:632 derives is_agent from agent_owner_pubkey.is_some() alone, and :658 switches the limit from human_messages_per_min (60) to agent_standard_messages_per_min (120). Any direct member can double its own quota with a throwaway keypair.
  • set_agent_owner is first-write-wins. A single authentication with a wrong or stale tag permanently pins that mapping — the legitimate owner is refused afterwards and materialized comes back false. That's durable corruption of the ownership record, not just a transient privilege bump.

The framing in my commit message cited the agent rate class as a benefit of the fix. It's also the abuse vector, and I didn't see that.

I'll take your first option — require the claimed owner to satisfy the relay trust policy on closed relays, mirroring what ViaOwner already demands — rather than the metadata/authorization split, which needs a schema change to represent an untrusted owner. Worth noting for anyone tracking #4223: the stricter version still fixes the original bug, because the deployments that hit it enrol the agent as a member while the owner is a member too.

Note allow_nip_oa_auth stays out of this path deliberately, since its own doc comment scopes it to granting membership; the trust boundary I'm adding is owner-membership. Say the word if you want the flag consulted as well.

Non-blockers all confirmed, including buzz-auth/src/lib.rs:75, whose "None for direct relay members" is precisely what this PR stops being true.

Re-rolling with these. Thanks for the depth here — reproducing the failures on the parent before attributing them to the PR is more care than a review usually gets.

@rmichelena

Copy link
Copy Markdown
Author

Pushed 3a3806ea with blockers 1 and 2 addressed. Blocker 3 is half done and I want to be precise about which half.

Blocker 2 — owner trust boundary

The claimed owner must now be a relay member on closed relays, mirroring what ViaOwner already demands. Open relays are unchanged, since there is no membership boundary there to check against.

allow_nip_oa_auth deliberately stays out of the path — its own doc comment scopes it to whether NIP-OA may grant membership, which this never does. Requiring it would leave #4223 unfixed on deployments that run with it off while enrolling agents directly. Say the word if you want it consulted anyway.

Worth recording that the blast radius was slightly wider than either of us wrote: set_agent_owner is first-write-wins, so a single authentication with a bogus tag pinned that mapping permanently and the legitimate owner was refused afterwards. Not just a transient quota bump — durable corruption of the ownership record.

Blocker 1 — time bounds

evaluate_time_bounds + verify_auth_tag_at in buzz-sdk, evaluated against the created_at of the signed authentication event, per your framing. enforce_request_auth_time_bounds now delegates to the shared evaluator instead of keeping its own copy, so the NIP-IA handler and the ownership path cannot drift. Its existing test still passes unchanged, which is what pins the strict-at-both-edges semantics.

Three deliberate choices:

Bounds are enforced even when the gate already resolved an owner. check_relay_membership does not evaluate them, so an expired tag can still produce a ViaOwner decision. I left that pre-existing membership grant alone — it is a wider change across eight call sites and deserves its own review — but it can no longer be materialized into an ownership record. If you'd rather I close the membership half here too, I'll do it; it's the same evaluator, just plumbed through enforce_relay_membership.

Time bounds gate granting, never denying. extract_nip_oa_owner keeps its signature-only behaviour for the ban cascades in handlers::auth and api::git::transport. An expired attestation must not become an escape hatch from an owner ban — widening who gets denied is safe, widening who gets trusted is not. The two entry points are named and documented for that split.

No NIP-98 event means no ownership. verify_bridge_auth now reports the request event's created_at; it's None under X-Pubkey dev auth, where nothing was signed. That case records no owner rather than treating the tag as unbounded.

Blocker 3 — the pure tests are gone, the real ones are owed

The four tests you flagged are replaced with coverage of what is actually pure: time bounds at both strict edges, the signature binding, and the grant/deny asymmetry above. I'm not claiming those close the gap — they don't, and I'd rather say so than dress them up.

The Postgres-backed tests through HTTP /events and NIP-42 are still outstanding, along with the CI filter that selects them. As noted above, api::relay_members::tests is run by no job today, so the tests and the wiring have to land together or the acceptance criterion is satisfied only on paper. I don't have Postgres available where I'm working and I'm not going to write DB tests I can't execute; I'm sorting that out and they'll come as a follow-up commit on this branch.

Verification

cargo clippy -p buzz-relay -p buzz-sdk -p buzz-auth --all-targets -- -D warnings   clean
cargo fmt --all --check                                                            clean
cargo test -p buzz-sdk --lib      261 passed; 0 failed
cargo test -p buzz-auth --lib      45 passed; 0 failed
cargo test -p buzz-relay --lib    863 passed; 8 failed

Those 8 are infra-dependent (api::media, api::admin) and fail identically on 9bb913f2 with the changes stashed — same eight names, same count — so they are my missing Postgres, not a regression. Your run saw 869/2 with a database present. One further caution from doing that comparison: api::invites::tests::claim_limiter_expires_entries failed in one run out of three and passed in the others, so it looks timing-sensitive and flaky rather than related to anything here.

Non-blockers are all corrected, including AuthContext::agent_owner_pubkey, whose doc now also warns that the field selects the agent rate class — that coupling is what made blocker 2 reachable, and it deserved to be written down next to the field rather than a hundred lines away in connection.rs.

@rmichelena

Copy link
Copy Markdown
Author

Blocker 3 is closed. 0bf52ba2 adds seven Postgres-backed tests through both production paths, plus the CI step that selects them.

Acceptance criterion, verified by doing it

I reverted each production fix in turn and confirmed the corresponding test fails:

reverted fails
bridge.rs (HTTP) nip_oa_owner_http_records_owner_for_direct_member, ..._refuses_an_expired_attestation
handlers/auth.rs (WebSocket) nip_oa_owner_ws_records_owner_and_sets_auth_context

That doubles as proof the tests actually execute rather than skipping on a missing database — a skipped test cannot fail.

Worth stating plainly: only the positive-recording cases discriminate. With the old code nothing is ever materialized on a closed relay, so the refusal cases hold vacuously against it. They guard the new trust boundary, not the original regression, and I'd rather say which test earns which claim than present seven green checks as if they were interchangeable.

What they cover

Entry is at the production call sites — submit_event_authed (the authenticated core of POST /events; everything outside it is NIP-98 verification and the attribution log) and handle_auth itself.

  • direct member + valid tag → owner recorded
  • non-member owner → refused
  • expired attestation → refused, and the same tag accepted one second inside its window, so the refusal is attributable to the time bound rather than to some unrelated rejection
  • no tag → nothing recorded
  • WebSocket: owner reaches the live AuthContext, which is what observer-frame authorization and the agent rate class read

One incidental finding: NIP-42 rejects a stale AUTH event, so the WebSocket tests stamp at the real clock and express bounds relative to it. That is also how a live deployment presents an expiring credential, so it is the more faithful shape anyway.

The CI step is not optional here

Without it these would satisfy the criterion on paper and never run. As noted earlier: just test-unit does not list buzz-relay, run-tests.sh integration only picks up tests/ targets and this crate has none, and ci.yml selected exactly two buzz-relay modules by name. The new step selects by test name (test(/nip_oa_owner_/)) rather than by module, because the tests span api::bridge and handlers::auth, and it sets REDIS_URL alongside DATABASE_URL since the submit path takes the NIP-98 replay guard.

If you'd prefer this crate's whole unit set to run in CI rather than a third named filter, that's a bigger change than this PR should carry, but it is the actual fix and I'm happy to open it separately.

Verification

cargo nextest/test -p buzz-relay --lib nip_oa_owner -- --ignored   8 passed; 0 failed
cargo test -p buzz-relay --lib                                   866 passed; 5 failed; 47 ignored
cargo test -p buzz-sdk --lib                                     261 passed; 0 failed
cargo test -p buzz-auth --lib                                     45 passed; 0 failed
cargo clippy -p buzz-relay -p buzz-sdk -p buzz-auth --all-targets -- -D warnings   clean
cargo fmt --all --check                                                            clean

The 5 are api::mesh_demo and tunnel::*. I ran the identical command at 9bb913f2 against the same database and got the same five names and the same count, so they are pre-existing and unrelated. Notably they fail only with a database present and pass without one, which given they do Redis-keyed fence/ownership acquisition looks like shared-Redis interference between tests rather than anything about this change — flagging it in case it is news to you, but I have not investigated further since it is outside this PR.

Also still true from my earlier comment: api::invites::tests::claim_limiter_expires_entries is timing-flaky, failing roughly one run in three independently of these changes.

ravarora2
ravarora2 previously approved these changes Aug 18, 2026

@ravarora2 ravarora2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, will leave for owners for the final approval

@rmichelena

Copy link
Copy Markdown
Author

Thanks for the rebase and for the outer POST /events coverage — that test closes the
seam I was most worried about, since the signed created_at has to survive the router
before it can bound the attestation. And we independently landed the same
BridgeAuth destructuring in api/workflows.rs; I had hit that same silent break
earlier today (different files, clean automerge, E0308), and your fix is identical to
the one I had.

Pushed one small follow-up on top: 3d4e728, dropping this branch's Cargo.lock change.

The webbrowser 1.2.4 bump the branch was carrying for RUSTSEC-2026-0257 landed
upstream on its own, so after your rebase that commit no longer moves the dependency —
not one added or removed line in the lockfile mentions webbrowser, it only survives as
diff context. What remained was 24 lines of windows-sys churn (0.52.00.59.0,
0.60.20.61.2) inside a change about the relay's authentication path, which just
gives a reviewer an unrelated dependency move to reason about. The branch touches no
Cargo.toml, so the base lockfile is by construction the right resolution for it. The PR
now carries no lockfile change at all.

Verified on the rebased branch with your new test included: cargo clippy -D warnings --all-targets over buzz-relay, buzz-sdk and buzz-auth exits 0, cargo fmt --check
exits 0, and the 26 nip_oa tests pass.

Happy to drop or squash that commit if you would rather keep the branch as you left it —
say the word and I will not push again while you are working on it.

@rmichelena

Copy link
Copy Markdown
Author

Empirical reproduction on a production closed relay

I reproduced this on a live self-hosted relay running stock upstream 417eea223 (no patch applied), as a controlled A/B. The only variable is enrollment order.

Relay config: BUZZ_REQUIRE_RELAY_MEMBERSHIP=true, BUZZ_ALLOW_NIP_OA_AUTH=true. Both probes are throwaway keypairs attested to the same owner, and both perform the identical write — a kind:0 profile via POST /events with a valid x-auth-tag.

Enrollment order Write users.agent_owner_pubkey
A (a267accd…) add-member before first auth accepted NULL
B (649ab0d5…) never a relay member accepted 4fd3b281… (owner) ✅
a267accd55208fcc24247d04b1d589dd95071da913d97b45c99d52e5262b0c28|t
649ab0d5c151159ace84ba643996375b367ab20b233939018168b492b68c6da9|f|4fd3b281…

B is the control, and it matters: it rules out "maybe kind:0 never materializes an owner for anyone". The write path works — it is direct membership that suppresses it. B was admitted through MembershipDecision::ViaOwner (api/mod.rs:101), so enforce_relay_membership returned Ok(Some(owner)) (:133) and bridge.rs:827 materialized it. A short-circuited at if is_member (api/mod.rs:78), returned Ok(None), and the or_else recovery at bridge.rs:814 is gated off precisely when require_relay_membership is true.

So on a closed relay the natural provisioning order — enroll the agent, then start it — is the one that silently loses ownership. The stricter deployment is the only one that never records it.

The assumption this falsifies

The comment above the second gate states the premise directly:

// Only runs on open relays — on closed relays, enforce_relay_membership already handles NIP-OA delegation.

That holds for non-members (case B) and fails for direct members (case A), who are exactly the population the comment is meant to cover. resolve_nip_oa_owner in this PR closes that: it keeps the delegated owner when membership came through one, and otherwise verifies the presented tag — including for a direct member, whose attestation is no less self-proving.

One nuance worth knowing, because it makes this easy to miss

An earlier attempt used an authenticated read (POST /query) and found no users row at all. That path never reaches either materialization site, so it neither creates the row nor materializes the owner. A smoke test built on a read returns 200 with ownership still NULL — a false green. Only POST /events and the NIP-42 AUTH frame exercise this.

Downstream, a NULL owner is not cosmetic: owner_only channel policies have nothing to match, kind:24200 observer frames are refused, and connection.rs derives is_agent from the session's agent_owner_pubkey, so the agent is rate-limited at the human tier. That last one has no ops workaround — a SQL backfill does not repair it, because the field is read from session context with no DB fallback and re-auth re-runs the same gate.

Both probes were cleaned up (relay_members and channel_members both 0); their inert users rows remain. No existing identity was modified — no UPDATE/DELETE was run against users.

Happy to re-run the same A/B against a build of this branch if that would be useful for the review.

ravarora2
ravarora2 previously approved these changes Aug 19, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Blocking (high): expired NIP-OA credentials still grant virtual relay membership.

check_relay_membership grants a non-member agent access through a member owner using verify_auth_tag, which verifies syntax/signature but deliberately does not evaluate created_at conditions. The new SDK contract says authority-granting callers, explicitly including session admission, must use verify_auth_tag_at; its regression test confirms that verify_auth_tag accepts both expired and not-yet-valid credentials. Once this returns ViaOwner, NIP-42 authentication succeeds. The later resolve_nip_oa_owner call only prevents materialization/rate elevation and explicitly leaves the membership grant intact.

That violates the repository's NIP-AA admission contract (docs/nips/NIP-AA.md, steps 4–6): a non-member agent holding a once-valid capability can reconnect to a closed relay after expiry, or before validity, and retain relay read/write access while its owner remains a member. The same shared gate is used by the HTTP, Git, media, workflow, and audio entry points; for example, /events passes the signed NIP-98 created_at only to post-admission owner resolution, so an invalid time window does not reject the request.

Please carry the verified carrier event's signed created_at into check_relay_membership / enforce_relay_membership and use verify_auth_tag_at before returning ViaOwner. Delegated fallback should be denied where no trusted timestamp is available. Add regressions with a non-member agent and member owner proving expired and not-yet-valid credentials are denied; the current expiry tests use a directly enrolled agent, so they cannot catch the admission bypass.

Reviewed exact head 3d4e72899a8b3ef7ad2e03f5f6992704cca48988 against true merge base b74700daafa823e56c60b4e6470740ab28330888.

@rmichelena

Copy link
Copy Markdown
Author

Two field observations from running this on a closed relay, plus a downstream consumer.

The flag is already on, so "enable it" is not the workaround. On our production relay (require_relay_membership = true):

BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
BUZZ_ALLOW_NIP_OA_AUTH=true

read from the running container, not from a .env. The ViaOwner branch is therefore not disabled — it is unreachable for an already-provisioned agent, because check_relay_membership returns at is_member before reaching it. Worth stating plainly, because two people on our side independently triaged this as a missing feature flag before reading the ordering.

A concrete cost, if it helps prioritise. agent_owner_pubkey being NULL is the reason clients cannot distinguish an agent from a human on such a relay. Downstream of that, an agent that self-joins a channel is recorded as member and disappears from @mention autocomplete with no in-app repair path — filed as #6561 with a fix in #6562.

#6562 is a no-op until this lands. It classifies self-joining agents using agent_owner_pubkey IS NOT NULL (the discriminator buzz_db::usage::user_counts already relies on). On a closed relay that field is exactly what this PR restores, so where the owner never materialises the discriminator reports every agent as a human and the fix does nothing. Flagging the dependency in both directions rather than letting someone discover it after merging one of them.

Nothing in the diff here needs to change on account of any of this — the narrow scope (dropping the conditional inside the or_else, leaving check_relay_membership's authorization semantics alone) still looks like the right call, and the reachability finding is an argument for it rather than against.

@rmichelena

Copy link
Copy Markdown
Author

Confirmed and fixed in bd069c3a6. You were right, and the gap was documented in this branch rather than closed — which made it worse, not better.

Verified before accepting

check_relay_membership granted ViaOwner from verify_auth_tag, which verifies the signature and deliberately skips the created_at clauses. NIP-AA.md Step 4, item 9 is unambiguous about the requirement:

Evaluate any created_at<t and created_at>t clauses against the AUTH event's created_at field. If the AUTH event does not satisfy a timestamp clause, reject.

So this was not a judgement call about hardening — it was the documented admission contract, unmet.

The bypass predates this branch. Leaving it was still indefensible here specifically: this branch introduces verify_auth_tag_at and writes the contract that authority-granting callers must use it, then left the most authority-granting caller in the tree on the signature-only entry point, with a comment calling it out of scope. A contract shipped beside a documented violation of itself is worse than no contract — it lends the hole formal cover. Thanks for not letting that through.

What changed

check_relay_membership and enforce_relay_membership now take the carrier event's signed created_at, and the ViaOwner branch verifies through verify_auth_tag_at.

The parameter is Option<u64> and None denies the delegated fallback rather than skipping the check, per your note about missing trusted timestamps. That choice is deliberate: it puts the fail-closed rule in the type, so a future call site that cannot supply a signed timestamp is denied instead of silently reopening this. Direct membership is untouched — it never consults the tag.

All nine call sites pass the timestamp from the signed artifact they had already verified (NIP-42 AUTH event, or NIP-98 request event). Two bridge read handlers needed it threaded from BridgeAuth, which already carried it.

Regression

Added at the admission layer, which is where the existing tests could not reach — your read of them was correct. They enrol the agent directly, so admission never consults the tag and they can only prove an expired credential confers no ownership.

nip_oa_delegated_admission_refuses_credentials_outside_their_window leaves the agent off the member list so the tag is the sole basis for access, and asserts the connection is not authenticated for both an expired and a not-yet-valid credential.

One caveat I would rather state than have you discover: that test is #[ignore]-gated on Postgres and Redis like its siblings, and I do not have that infra locally, so I have compiled it but not executed it — CI is the first run. cargo clippy -p buzz-relay --all-targets -- -D warnings is clean and the non-infra tests pass. If CI shows it green I will say so here; if it does not, the failure is mine to fix.

Also removed the now-false comment in resolve_nip_oa_owner that described the bypass as pre-existing and out of scope.

@rmichelena
rmichelena requested a review from wesbillman August 22, 2026 18:04
@rmichelena

Copy link
Copy Markdown
Author

Follow-up on my previous comment, correcting it.

The regression I added in bd069c3a6 was a false green. I said CI would be its first run; I went and stood up Postgres and Redis locally instead, and the test passed against the unfixed code too. ws_test_state only forces require_relay_membership; allow_nip_oa_auth defaults to false, so the delegated branch was never entered. Both denial cases passed because delegation was disabled outright — not because the credential was outside its window. It asserted nothing.

Fixed in 101994573:

  • ws_test_state_with enables the flag for this test without changing what existing callers get.
  • An unbounded credential is added as a positive control. That is the load-bearing part: it proves the delegated branch is actually reachable under this config, which is precisely what the first draft failed to establish.

Re-verified by mutation against real infra:

check_relay_membership Result
verify_auth_tag_at (this PR) 11/11 NIP-OA tests pass
reverted to verify_auth_tag fails on the assertion — "expired: a non-member agent reaching a closed relay through its member owner must be admitted only inside the attested window"

The failure is an assertion, not an infrastructure error, so the test now demonstrably catches the bypass you identified.

cargo clippy -p buzz-relay --all-targets -- -D warnings clean.

One thing worth flagging separately: no CI has run on this PR at any pointbd069c3a6 and the earlier 3d4e72899 each show only DCO Check, with no workflow runs queued. If that is the fork-PR approval gate, the full suite has never exercised this branch, so please do not read the absence of red as a pass. Local runs are all it has had so far, and now they are at least real ones.

On a closed relay an agent that is itself a relay member never gets its
NIP-OA owner recorded, however many valid attestations it presents.
`enforce_relay_membership` returns `Ok(None)` as soon as direct membership
is established, and the only owner-extraction fallback at either call site
was gated on `!require_relay_membership` — off precisely where it is needed.

With `agent_owner_pubkey` left NULL the relay cannot tell the agent from a
human: `owner_only` policies have nothing to match, observer frames are
refused, and the connection is rate-classed as human. Closes block#4223, block#4937.

`relay_members::resolve_nip_oa_owner` settles the question once for both
call sites. Admission by delegation keeps the owner it already proved is a
member; an open relay keeps today's behaviour; a direct member on a closed
relay has its attestation resolved, with the owner required to be a relay
member.

That membership requirement is load-bearing. `materialize_nip_oa_owner` is
first-write-wins and the recorded owner selects the agent rate class, so
without it any member could attest itself with a throwaway key, raise its
own rate budget, and bind the mapping permanently against the real owner.

`allow_nip_oa_auth` deliberately does not gate this: per its own doc
comment the flag governs whether NIP-OA may *grant membership* on a closed
relay, and nothing here grants access — the caller is already in.

Time bounds now come from block#7004 (`verify_auth_tag_for_auth_event` and the
`signed_created_at` carried on `VerifiedBridgeAuth`), so this branch builds
on those primitives rather than duplicating them.

Nine tests exercise both production paths against real PostgreSQL and
Redis: `submit_event_authed` and the full `POST /events` router, plus
`handle_auth` on the WebSocket side. They live in `postgres_tests` modules
so the structural PostgreSQL discovery lane picks them up, which is why
this branch no longer patches `ci.yml`.

Co-authored-by: Ravneet Arora <rarora@squareup.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
@rmichelena
rmichelena force-pushed the bumble/nip-oa-owner-closed-relay branch from 1019945 to 2a02440 Compare September 13, 2026 20:55
@github-actions

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 4cd82f513214aad11c2b742ce7cc7c681e8e32a0...2a02440b50dfbd2fe4bf58759c1eff9764e7f488.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 2a02440b50dfbd2fe4bf58759c1eff9764e7f488 to authorize a new review.
Any previous review applies only to its recorded range.

@rmichelena

Copy link
Copy Markdown
Author

Rewritten on top of main (4cd82f513) — 2a02440b5, MERGEABLE. The branch is now 3 files instead of 13, and the PR description has been rewritten to match. Force-push rather than a merge commit because the point of the revision is that the diff shrinks; nobody else has pushed here since August.

@wesbillman / Carl — blocking comment (1) is resolved upstream, not by me. #7004 landed the NIP-OA time-bound evaluation on 31 Aug, with the same shape this branch carried under different names: VerifiedBridgeAuth.signed_created_at where this had BridgeAuth, and verify_auth_tag_for_auth_event where it had verify_auth_tag_at. check_relay_membership now evaluates the clauses before granting delegated admission, which is exactly what the review asked for. Everything this branch had on that subject is deleted; it consumes the merged primitives instead.

What is left is the part main still does not do: resolve the attestation for an agent that is already a direct relay member. resolve_nip_oa_owner does not exist upstream and the !require_relay_membership gate is still in place at bridge.rs:933 and handlers/auth.rs:247, so #4223 and #4937 are live on main today.

The trust boundary from the earlier review is kept and tested: on a closed relay the attested owner must itself be a relay member, because the mapping is first-write-wins and the recorded owner selects the agent rate class.

@ravarora2 — your two outer-NIP-98 tests are carried forward with Co-authored-by and a note in each doc comment. They still earn their place: the records_owner_for_direct_member one fails under mutation A below, so it is discriminating against this fix, not just against the old one.

Verification

Nine tests, real PostgreSQL and Redis, both production paths (submit_event_authed, the full POST /events router, and handle_auth): 9 passed, 0 failed.

Each half of the fix was proven by reverting it:

Mutation Result
restore main's gate — never resolve on a closed relay 5 of 9 fail
drop the owner-must-be-a-member requirement 2 of 9 fail (refuses_an_owner_that_is_not_a_relay_member, HTTP and WS)

The four survivors of mutation A pass vacuously against the old code, which materialized nothing at all on a closed relay — that is why every expiry test carries an inside-window positive control. Saying so explicitly because an earlier revision of this branch shipped a test that passed for exactly that reason.

fmt 0 · clippy --all-targets -D warnings 0 · cargo test -p buzz-relay --lib 1039/1, the one failure being mesh_demo::demo_join_forwarded_arm_round_trips_echo (504, module untouched here) which passes in isolation at the same commit.

One process note

The ci.yml step this branch used to add is deleted. main's structural PostgreSQL discovery supersedes it: the tests sit in postgres_tests modules with #[ignore = "requires PostgreSQL and Redis"], scripts/check-postgres-test-discovery.py crates/buzz-relay/src validates, and scripts/postgres-test-packages.sh lists buzz-relay. That is a strictly better mechanism than selecting tests by name, and it is worth noting that it closes the hole I flagged in August, when buzz-relay's ~900 unit tests ran in no CI job at all.

Separately: no CI has run on this PR since 18 August, and the runs from then have started expiring at GitHub's 30-day mark — which concludes them as failure with zero jobs executed. If you see a red ❌ on an older commit here, that is the expiry, not a test. An Approve and run workflows would give the current head its first real signal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage-ready Appropriate for agentic review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants