fix(relay): record the NIP-OA owner for direct members on closed relays - #5581
fix(relay): record the NIP-OA owner for direct members on closed relays#5581rmichelena wants to merge 5 commits into
Conversation
On a closed relay, an agent that is a direct relay member never got `users.agent_owner_pubkey` recorded, even with a valid NIP-OA auth tag. `check_relay_membership` short-circuits on direct membership and consults the tag only as a membership fallback for non-members, so the two materialization sites re-derived the owner behind a `!require_relay_membership` conditional and dropped it. The posture was inverted: the stricter deployment was the only one that never recorded ownership, and enrolling an agent as a member — the natural provisioning order — is what broke it. Downstream, `owner_only` policies had no owner to match, observer frames (kind 24200) were refused, and the agent was rate-limited at the human tier, because `connection.rs` derives `is_agent` from the session's `agent_owner_pubkey`. Resolve the owner in one shared, pure helper used by both sites: keep the delegated owner when membership came through one, otherwise verify the presented tag. A direct member's attestation is just as self-proving — the flag's own doc comment says extraction is unconditional, and the ban cascade in the same handler already trusts the same tag with no relay openness check. `allow_nip_oa_auth` still governs only whether NIP-OA can grant membership. Open relays are unaffected; callers that don't record ownership keep using the membership gate unchanged. Refs block#4223, block#4937, block#4260. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
…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. |
Review of the previous commit found two ways it granted authority from an attestation that had not earned it, plus a test gap that hid both. **The claimed owner must be trusted, not merely attested.** On a closed relay the only prior path to an owner was `ViaOwner`, which requires the owner to be a relay member. `resolve_nip_oa_owner` bypassed that for direct members: any member could mint a throwaway keypair, attest itself, and have that key recorded. The resolved owner is not inert — `materialize_nip_oa_owner` creates a user row for it, and `connection.rs:632` derives `is_agent` from `agent_owner_pubkey.is_some()` alone, switching the message limit from 60/min to 120/min. So a member could double its own quota. Worse, `set_agent_owner` is first-write-wins: one authentication with a wrong or stale tag pins that mapping permanently and the legitimate owner is refused afterwards, which is durable corruption rather than a transient privilege bump. The claimed owner must now be a relay member on closed relays, exactly as `ViaOwner` demands. Open relays are unchanged — with no membership boundary there is nothing to check against. `allow_nip_oa_auth` stays out of it: its own doc comment scopes it to granting *membership*, which this never does. **A signature that verifies is not a credential that is valid.** `validate_conditions` is purely syntactic — it checks `created_at<N` is a canonical decimal and never compares N to anything — so an expired tag was indistinguishable from a live one. `evaluate_time_bounds` and `verify_auth_tag_at` add that evaluation, judged against the `created_at` of the signed authentication event that carried the tag (the NIP-42 AUTH event, or the NIP-98 request event) rather than wall clock, because the bound is a property of what the owner authorized. The semantics are not invented here: `enforce_request_auth_time_bounds` already enforced these clauses for NIP-IA archive requests with both bounds strict, and now delegates to the shared evaluator so the two cannot drift. The bounds are enforced even when the membership gate already resolved an owner, since that gate does not evaluate them — an expired tag can still produce a `ViaOwner` decision. Leaving that pre-existing membership grant alone is deliberate and out of scope here; what changes is that it can no longer be *materialized* into an ownership record. 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`: widening who gets denied is safe, and an expired attestation must not become an escape hatch from an owner ban. `verify_bridge_auth` now reports the NIP-98 request event's `created_at`. It is `None` under X-Pubkey dev auth, where nothing was signed and there is no timestamp to judge bounds against; no ownership is recorded in that case rather than treating the tag as unbounded. The four unit tests this replaces did not protect the regression — reverting both production call sites left them green, because they exercised the helper rather than its callers. They are replaced with coverage of what is genuinely pure (time bounds, signature binding, and the grant/deny split). The Postgres-backed tests through the real HTTP and NIP-42 paths are still owed, along with the CI filter that selects them: no job currently runs `api::relay_members::tests` at all. Also corrects three doc comments the previous commit falsified, including `AuthContext::agent_owner_pubkey`, which still claimed `None` for direct relay members. Refs block#4223, block#4937, block#4260. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
|
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 |
The unit tests this replaces did not protect the regression: reverting both production call sites left them green, because they exercised the helper rather than its callers. Seven Postgres-backed tests now enter at the production call sites — HTTP at `submit_event_authed`, the authenticated core of `POST /events`, and WebSocket at `handle_auth` itself. Verified by reverting each fix in turn: reverting the HTTP one fails `nip_oa_owner_http_records_owner_for_direct_member` and `..._refuses_an_expired_attestation`; reverting the WebSocket one fails `nip_oa_owner_ws_records_owner_and_sets_auth_context`. That also proves the tests genuinely execute rather than skipping — a skipped test cannot fail. Only the positive-recording cases discriminate. With the old code nothing is ever materialized on a closed relay, so the refusal cases hold vacuously there; they guard the new trust boundary, not the original regression. Coverage: owner recorded for a direct member; non-member owner refused; expired attestation refused, with the same tag accepted one second inside its window so the refusal is attributable to the time bound and not to some unrelated rejection; no tag records nothing; and on the WebSocket path the owner reaching the live `AuthContext`, which is what observer-frame authorization and the agent rate class both read. **And a CI step that selects them.** Without it these would satisfy the acceptance criterion on paper and still never run: `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 just two `buzz-relay` modules by name. Every unit test in this crate — including the four being replaced — has therefore never executed in CI. The new step selects by test name rather than module because the tests span `api::bridge` and `handlers::auth`, and sets `REDIS_URL` as well as `DATABASE_URL` since the submit path takes the NIP-98 replay guard. NIP-42 rejects a stale AUTH event, so the WebSocket tests stamp at the real clock and express the tag's bounds relative to it — which is also how a live deployment presents an expiring credential. Refs block#4223, block#4937, block#4260. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
|
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: |
Two diagnostics, both prompted by watching them mislead me. The tests returned early when Postgres or Redis was unavailable. They are `#[ignore]`d and run only when explicitly selected, so a silent skip is never what the caller wanted: it reports "the database was missing" as a passing run. That is the same false-green shape these tests exist to rule out, and it nearly cost me a wrong conclusion while verifying an unrelated report. Second, admission and the NIP-98 replay guard run before owner materialization and both fail closed on a Redis blip, short-circuiting the submit. The tests asserted only on the stored owner, so an infrastructure failure surfaced as "the owner was not recorded" — indistinguishable from a real regression in the owner path. `submit_with_tag` now panics on `SubmitOutcome::Err` naming the status, so the two are never confused again. This diverges from the silent-skip convention in `handlers::identity_archive` deliberately. That helper is shared with tests that are not explicitly selected; these are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
The Security job fails `cargo-deny advisories` on this branch: `webbrowser 1.2.1` is affected by RUSTSEC-2026-0257, where the caller-supplied URL is substituted into the `BROWSER` template before tokenizing, so a non-HTTP(S) URL retaining spaces can inject extra browser arguments. Nothing to do with this branch's changes — `webbrowser` is a direct dependency of `buzz-agent` (`webbrowser = "1"`), untouched here, and the advisory was published after this branch was cut. `main` already carries 1.2.4 and is green; this branch's base predates that, so the stale lockfile is what CI is flagging. Bumped with `--precise 1.2.4` to match `main` exactly rather than to latest. The incidental churn it drags in — `windows-sys` unification and the new `objc2-app-kit` — converges on `main`'s resolution rather than inventing a third one, so this should not add a merge conflict later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Roberto Michelena <77797875+rmichelena@users.noreply.github.com>
Fixes #4223. Also fixes the cluster reported in #4937 (rate class, owner context, backfill).
The bug
On a closed relay (
require_relay_membership = true), an agent that is a direct relay member and presents a valid NIP-OAauthtag never getsusers.agent_owner_pubkeyrecorded. The attestation is accepted for transport and then dropped.check_relay_membershipshort-circuits on direct membership (api/mod.rs:77-79) and only consults the tag as a membership fallback for non-members, soenforce_relay_membershipreturnsOk(None)for a member. Both materialization sites then re-derived the owner behind the same conditional:The posture is inverted: the stricter deployment is the only one that never records ownership, and enrolling an agent as a member — the natural provisioning order — is what breaks it.
Why this is a correctness fix, not a policy change
Three places in the tree already say the tag should be believed unconditionally:
config.rs:213-218, on the flag itself: "extraction for agent→owner backfill happens unconditionally (the signature is cryptographically self-proving). This flag only controls whether NIP-OA can grant membership access on closed relays." That describes the behavior this PR implements, as though it were already true.extract_nip_oa_owner's own doc comment: "cryptographically self-proving, so no feature flag is needed."handlers/auth.rs:137) and its git counterpart (api/git/transport.rs:257) extract the owner with no relay-openness condition — because a ban on a human must reach their agents. The same tag, in the same function, was trusted for denying access and discarded for recording ownership.allow_nip_oa_authis untouched: it still governs whether NIP-OA can grant membership, which is the only thing its doc comment claims.Change
One shared, pure helper —
relay_members::resolve_nip_oa_owner(gate_owner, pubkey, auth_tag)— keeps the delegated owner when membership came through one, and otherwise verifies the presented tag. Both materialization sites call it.Doing it in one place rather than fixing each
or_elseis deliberate: the two call sites were identical copies, which is how they drifted from the intent in the first place, and a pure function is unit-testable without Postgres or Redis (the existing tests in this module cover onlyextract_nip_oa_owner).I also corrected
enforce_relay_membership's doc comment, sinceOk(None)reading as "no owner" rather than "admitted on its own" is what the two call sites got wrong.No behavior change on open relays (the
or_elsethere already extracted), and none for callers that don't record ownership —media.rs,audio/handler.rsand the git transport keep using the membership gate exactly as before.What this restores on closed relays
channel_add_policy: owner_onlyunenforceable — no owner to matchrestricted: observer frame is not authorized for this agent ownerhandlers/event.rshuman_messages_per_mininstead ofagent_standard_messages_per_minconnection.rs:632,:659-661The last two are the reason this needs the
auth.rshalf: both readagent_owner_pubkeyfrom the session auth context, with no DB fallback, so a DB-level repair doesn't help a live connection and reconnecting re-runs the same gate.Considered consequence
An agent admitted as a direct member can now enter the agent rate class by presenting a self-minted attestation (any keypair can attest any other). That authority is not new — the open-relay path and the
ViaOwnerpath already accept exactly the same self-proving tag — and on a closed relay the actor must already be an admitted member. Flagging it explicitly rather than leaving it implicit: if maintainers want the agent rate class to require something stronger than a valid NIP-OA tag, that's a separate discussion about the rate class, not about which membership branch was taken.Existing rows stay
NULLuntil the agent next authenticates or submits; this is a fix-forward, not a migration.Relation to #4260
#4260 (@iroiro147, Aug 2) fixes the same conditional in
bridge.rs— the HTTP path — with the same reasoning. It's a correct diagnosis and it predates this PR. What it doesn't cover ishandlers/auth.rs, which is where the WebSocket session context is built, so the rate class and observer-frame consequences survive it.Happy to go either way: reduce this to the
auth.rshalf plus tests on top of #4260, or land this and close that one — whichever maintainers prefer. I'd rather not have two open PRs on one conditional.Tests
Four new unit tests next to the existing
extract_nip_oa_ownerones (cargo test -p buzz-relay --lib, no infrastructure needed):Also run:
cargo fmt --check,cargo clippy -p buzz-relay --all-targets -- -D warnings.Local run on this branch:
cargo fmt --checkclean,cargo clippy -p buzz-relay --all-targets -- -D warningsclean,cargo test -p buzz-relay --lib→ 863 passed / 8 failed, where all 8 are theapi::admin/api::mediatests that need Postgres (Sqlx(PoolTimedOut)) and none touch this path — no Docker on this machine, so CI is the real check there.