fix(relay): record the NIP-OA owner for direct members on closed relays - #5581
rmichelena wants to merge 1 commit into
Conversation
…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>
|
🤖 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
Non-blockers
Verification evidence
Review scope: commit |
|
Accepting all three blockers. I verified each against Blocker 3 — confirmed empirically, and it's worse than statedI reverted both production call sites ( 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
So the fix is to follow the Blocker 1 — correct, and the evaluator already exists in-tree
Two things worth putting on the record. First, this is not new in this PR. Second, the semantics you asked me to pin are already pinned in-tree. 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<200So 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 doingI traced the consequences and they're a little wider than described:
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 Note Non-blockers all confirmed, including 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. |
|
Pushed Blocker 2 — owner trust boundaryThe claimed owner must now be a relay member on closed relays, mirroring what
Worth recording that the blast radius was slightly wider than either of us wrote: Blocker 1 — time bounds
Three deliberate choices: Bounds are enforced even when the gate already resolved an owner. Time bounds gate granting, never denying. No NIP-98 event means no ownership. Blocker 3 — the pure tests are gone, the real ones are owedThe 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 VerificationThose 8 are infra-dependent ( Non-blockers are all corrected, including |
|
Blocker 3 is closed. Acceptance criterion, verified by doing itI reverted each production fix in turn and confirmed the corresponding test fails:
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 coverEntry is at the production call sites —
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 hereWithout it these would satisfy the criterion on paper and never run. As noted earlier: 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. VerificationThe 5 are Also still true from my earlier comment: |
aad4b9b to
c862839
Compare
ravarora2
left a comment
There was a problem hiding this comment.
lgtm, will leave for owners for the final approval
|
Thanks for the rebase and for the outer Pushed one small follow-up on top: The Verified on the rebased branch with your new test included: Happy to drop or squash that commit if you would rather keep the branch as you left it — |
Empirical reproduction on a production closed relayI reproduced this on a live self-hosted relay running stock upstream Relay config:
B is the control, and it matters: it rules out "maybe 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 falsifiesThe comment above the second gate states the premise directly:
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. One nuance worth knowing, because it makes this easy to missAn earlier attempt used an authenticated read ( Downstream, a NULL owner is not cosmetic: Both probes were cleaned up ( Happy to re-run the same A/B against a build of this branch if that would be useful for the review. |
wesbillman
left a comment
There was a problem hiding this comment.
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.
|
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 ( read from the running container, not from a A concrete cost, if it helps prioritise. #6562 is a no-op until this lands. It classifies self-joining agents using Nothing in the diff here needs to change on account of any of this — the narrow scope (dropping the conditional inside the |
|
Confirmed and fixed in Verified before accepting
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 What changed
The parameter is 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 RegressionAdded 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.
One caveat I would rather state than have you discover: that test is Also removed the now-false comment in |
|
Follow-up on my previous comment, correcting it. The regression I added in Fixed in
Re-verified by mutation against real infra:
The failure is an assertion, not an infrastructure error, so the test now demonstrably catches the bypass you identified.
One thing worth flagging separately: no CI has run on this PR at any point — |
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>
1019945 to
2a02440
Compare
🔐 Codex Security Review
|
|
Rewritten on top of @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: What is left is the part 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 VerificationNine tests, real PostgreSQL and Redis, both production paths ( Each half of the fix was proven by reverting it:
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.
One process noteThe 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 |
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_membershipreturnsOk(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:The comment above the WebSocket twin (
handlers/auth.rs:245) states the premise out loud — "on closed relays,enforce_relay_membershipalready handles NIP-OA delegation" — and that is the mistake: it handles delegation, which a direct member never reaches.With
agent_owner_pubkeyleft NULL the relay cannot tell the agent from a human.owner_onlypolicies have nothing to match, observer frames are refused, andconnection.rsrate-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:That last requirement is load-bearing, not decoration.
materialize_nip_oa_owneris 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_authdeliberately 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.ymlpatch is gone too.mainnow discovers PostgreSQL-backed tests structurally, so these live inpostgres_testsmodules with#[ignore = "requires PostgreSQL and Redis"]and the existing postgres lane picks them up.scripts/check-postgres-test-discovery.py crates/buzz-relay/srcvalidates, andscripts/postgres-test-packages.shlistsbuzz-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 fullPOST /eventsrouter, andhandle_authon the WebSocket side. 9 passed, 0 failed.Both halves of the fix were proven by breaking them:
main's gate (never resolve on a closed relay)records_owner_for_direct_member, the WebSocketrecords_owner_and_sets_auth_context, and the inside-window controls of the two expiry testshttp_refuses_an_owner_that_is_not_a_relay_memberand its WebSocket twinStated 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-relayexit 0 ·cargo clippy -p buzz-relay --all-targets -- -D warningsexit 0 ·cargo test -p buzz-relay --lib1039 passed / 1 failed, the failure beingapi::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.