Eva [11] code-verify catch: two docstrings in auth_wire.rs referenced state
that doesn't exist on this branch.
- AuthErrorWireCategory::message pointed at "tests/auth_error_payload_oracle.rs"
for the byte-identity property test. The tests are actually in-module
(#[cfg(test)] mod tests at the bottom of auth_wire.rs); no separate file
exists. Updated to name the two specific tests that prove the invariant
(verification_class_all_coalesce, internal_two_communities_byte_identical).
- auth_error_wire's docstring said "the CI grep-lint enforces" — but the
grep-lint (P5 in the audit note) is a planned follow-up, not yet shipped.
Replaced with an accurate description of what actually fences regressions
today: the wildcard-free match arm. Adding any AuthError variant fails to
compile here until a wire-class decision is made — that catches the cause,
not just the symptom. P5 grep-lint is correctly described as planned
belt-and-suspenders, not currently in CI.
Doc-only diff. No behavior change.
Validation:
- cargo fmt -p buzz-relay --check ✅
- cargo test -p buzz-relay --lib auth_wire ✅ (6/6 property tests green)
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Pairs with c31307d40 (the audit branch's wire mapper + property tests) and
Eva's [13] ruling: one wire categorization lives in buzz-relay::auth_wire,
and AuthError variants carry doc annotations pointing at it. Doc-only diff;
no behavior change.
The enum-level docstring spells out the wire-mapping contract — the four
AuthErrorWireCategory targets, what each invariant guards (byte-identity
on verification-class collapses an existence oracle; Internal must never
stringify on the wire), and a pointer at the compile-time exhaustiveness
fence in auth_error_wire's match that catches variant-add-then-forget.
Per-variant: each variant carries a "WIRE class:" line naming its
AuthErrorWireCategory target. Nip98Replay calls out the byte-identity
requirement against Nip98Invalid explicitly (community-scoped seen-set
presence oracle). Internal calls out the no-stringify rule explicitly
(community-prefixed Redis keys can ride the inner String).
No intra-doc link to AuthErrorWireCategory: buzz-auth cannot depend on
buzz-relay (would cycle), so the type is referenced by name in prose.
Validation:
- cargo fmt -p buzz-auth --check ✅
- cargo test -p buzz-auth ✅ (40 passed)
- cargo clippy -p buzz-auth --no-deps --lib --tests ✅ (one pre-existing
warning at nip98_replay.rs:162, not from this diff)
- cargo doc -p buzz-auth --no-deps ✅ (zero new doc warnings; two
pre-existing broken links in rate_limit.rs/nip98_replay.rs unchanged)
- cargo test -p buzz-relay ✅ in isolation; one known-flaky Redis
presence test (pubsub_fanout::global_presence_*) intermittently fails
in full-suite runs and passes when run alone — same flake Eva flagged
on ed33878b7's push. Doc-only diff cannot affect Redis fanout timing.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Per Eva's [12] handoff: the multi-tenant Redis keys (buzz:{community}:nip98:{id},
buzz:{community}:ratelimit:{hex}:{kind}) leak through AuthError::Internal(_) when
the relay wire converter stringifies the underlying redis::RedisError. The
bridge.rs:52 site forwarded the whole chain as the user-visible body —
`api_error(401, &format!("NIP-98: {e}"))` — turning the auth path into a
cross-tenant existence oracle on community-prefixed keys.
Two policy decisions land here as code + tests:
P1 — AuthError::Internal(_) NEVER reaches the wire as a string. The new
crate::auth_wire::auth_error_wire mapper maps Internal(_) -> InternalRedacted,
which produces a fixed "internal error" body regardless of the inner
String. The construction sites still log the detail (tracing::warn! at
construction is unchanged); only the wire converter redacts.
P2 — Nip98Invalid and Nip98Replay are byte-indistinguishable on the wire.
Returning a distinct error for replay tells an attacker that a guessed
event id has been seen in THIS community (the seen-set is community-scoped
per the S1 isolation fence). Both variants -> AuthErrorWireCategory::AuthFailed
-> identical status + body. Same reasoning collapses InvalidSignature,
ChallengeMismatch, RelayUrlMismatch, EventExpired, PubkeyMismatch into the
same category — all are "the auth artifact didn't verify."
Authorization-class outcomes (InsufficientScope, ChannelAccessDenied) stay
distinguishable on the wire — they're remediation-different (re-auth vs
re-request access) and don't carry tenant-scoped detail. If that ever
changes, authorization_class_distinguishable will need re-thinking.
The bridge.rs:52 site is the only current consumer; Sami's pending NIP-98
HTTP wire-up will use the same mapper. CI lint to forbid raw AuthError
stringification in response-construction is P5 in the audit note —
proposing that as a follow-up grep-lint Eva can wire into the §5 gate set.
Files:
- crates/buzz-relay/src/auth_wire.rs (new, +281 lines incl 6 property tests)
- crates/buzz-relay/src/api/bridge.rs:52 (use mapper, log detail)
- crates/buzz-relay/src/lib.rs (declare module)
Tests:
- replay_indistinguishable_from_invalid_on_wire (P2)
- verification_class_all_coalesce (P2 extended — 7 variants byte-identical)
- internal_redacted_does_not_leak_inner_string (P1 — synthesizes a leaked
Redis error containing buzz:<uuid>:nip98:<id> and asserts none of those
substrings appear on the wire)
- internal_two_communities_byte_identical (P1 — two distinct communities'
inner strings produce the same wire bytes; closes the cross-tenant oracle)
- authorization_class_distinguishable (negative — InsufficientScope and
ChannelAccessDenied stay distinct)
- ws_notice_redacts_internal_and_coalesces_verification (WS NOTICE parity)
Validation:
- cargo test -p buzz-relay --lib: 384 passed, 0 failed (378 prior + 6 new)
- cargo fmt -p buzz-relay --check: clean
- cargo clippy -p buzz-relay --no-deps --lib --tests: 0 new warnings
(one pre-existing on publish_nipia_unarchived at side_effects.rs:2619,
unrelated to this change — confirmed by git stash)
Base: rewrite/relay-wiring @ 87d5a8e35 (Eva's local merge of auth-tenant-scope
into relay-wiring; Sami's Nip98Replay variant is present here, NOT yet in
origin/rewrite/relay-wiring's pushed tip).
Holding for Eva's push of 87d5a8e35 + review per the audit lane handoff.
Audit note: RESEARCH/RELAY_REWRITE_AUTH_ERROR_ORACLE_AUDIT.md
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Land the auth fences from buzz-auth/buzz-pubsub into the relay's request
paths, replacing the per-pod moka NIP-98 cache that does not carry the
freshness proof under any-pod-any-connection (bus-scoping B).
- AppState: add Arc<RedisNip98ReplayGuard> + Arc<RedisRateLimiter>,
constructed from the existing redis_pool. Drop the dead nip98_seen moka
cache (its only caller was the bridge replay check).
- bridge.rs: check_nip98_replay is now async + tenant-scoped, calling the
shared seen-set's try_mark under the resolved community (the seen-set is
community-scoped per S1). Resolve the tenant BEFORE the replay check in
all three NIP-98 handlers; DRY the duplicated host-resolve into
resolve_request_tenant. /count now resolves a tenant (required for the
per-community replay check). Replay and invalid responses are
wire-indistinguishable (Quinn P2) and fail closed on Redis error.
- router.rs: per-IP connection fence (check_ip_connection) runs in the WS
upgrade path BEFORE host resolution, so an unmappable Host cannot bypass
the cap. Operator-global, tenant-free; fail-closed -> 429.
- config.rs: BUZZ_MAX_CONNECTIONS_PER_IP (default 60) +
BUZZ_IP_CONNECTION_WINDOW_SECS (default 60).
cargo test -p buzz-relay: 378 passed / 0 failed. clippy clean (one
pre-existing unrelated warning in side_effects.rs).
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Integrates Sami's auth lane (15dd8dfc3) into relay-wiring: community-scoped
rate_limit_key, RedisNip98ReplayGuard, and the IpConnections fence primitives.
Brings RateLimiter + Nip98ReplayGuard into the workspace so the relay can wire
the IP-fence-before-host ordering and the NIP-98 verify-then-mark call site.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
* origin/rewrite/auth-tenant-scope:
feat(auth): NIP-98 replay seen-set — shared, community-scoped, atomic
feat(auth): community-scope RateLimiter pubkey quotas
The dynamic refcounted Redis subscriber (Max's pubsub seam) only SUBSCRIBEs
a community-scoped topic once a pod declares local interest via retain_topic,
and debounce-UNSUBSCRIBEs on the last release. Until now nothing drove those
calls from real subscription activity, so cross-pod delivery only worked via
a manual retain in the presence test setup.
The SubscriptionRegistry stays synchronous and tenant-agnostic — it does not
grow a PubSubManager or TenantContext. Instead each mutator now reports the
routing topic(s) it added/removed:
- register -> Option<EventTopic> displaced by an in-place sub_id replace
- remove_subscription -> Option<EventTopic> removed
- remove_connection -> Vec<EventTopic>, one per removed sub
The call sites — where &conn.tenant and state.pubsub already live — apply the
delta: retain the new topic on REQ, release on CLOSE / connection drop /
channel-access revocation. The pubsub manager's own (community, topic)
refcount collapses N releases to one debounced UNSUBSCRIBE and keeps two
communities sharing a channel UUID isolated, since the community is bound at
the call site via ctx. Every subscription contributes exactly one retain at
creation and one release at teardown — balanced, no registry-side counter.
The presence cross-pod test now drives retain through the real register()
path (register_global_sub is async) instead of a manual workaround, proving
delivery from genuine subscription interest. Five new registry unit tests
pin the topic-reporting contract including the replace-displaces-old case.
cargo test -p buzz-relay: 378 passed, 0 failed. Redis round-trip presence
test exercised against local Redis.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Completes the &TenantContext threading for the relay-wiring lane so
buzz-relay compiles against Mari's buzz-db tenant seam and Max's
community-scoped pubsub seam. The remaining unscoped publish/dispatch
sites are now request- or startup-tenant bound:
- mesh_signaling: ctx threaded through handle_mesh_event_http,
handle_connect_request, handle_status_report, and
publish_channelless_ephemeral. publish_event(uuid::nil) ->
publish_event(ctx, EventTopic::Global); echo-cache invalidate keyed
by (community, event_id). WS callers pass &conn.tenant; the HTTP
bridge now resolves the tenant once before mesh/ingest routing.
- mesh_status_publisher: ctx threaded through publish_mesh_status* into
dispatch_persistent_event.
- workflow_sink: cron has no request host, so it resolves the
configured-host startup tenant (N=1) for dispatch; side effects stay
best-effort, logging+skipping on resolve failure. TODO(multi-tenant):
resolve per-channel community for N>1.
- main.rs: ephemeral reaper and NIP-ER reminder scheduler resolve the
startup tenant per tick (TODO multi-tenant per-channel); the cache-
invalidation receiver unwraps ScopedCacheInvalidation and threads
community_id into apply_cache_invalidation (moka cache keys still need
community-prefixing, flagged as follow-up).
- bridge.rs: hoisted tenant resolution above the mesh branch so one
resolve_tenant call binds both paths.
Tests updated for the new pubsub contract: mesh handler tests pass a
test_ctx(); the cross-pod presence round-trip test now retain_topic()s
the Global topic on both relays (the dynamic-subscription seam the
registry wiring will drive). cargo test -p buzz-relay: 373 passed.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Adds the §5 pre-build gate for multi-tenant replay protection.
buzz-auth gains a Nip98ReplayGuard trait plus the
nip98_replay_key(ctx, event_id) helper. The trait's try_mark contract
requires atomic set-if-absent semantics; an in-process cache (moka,
DashMap) does not carry the freshness proof across pods under the
"any pod, any connection" architecture (§4B), so the production
implementation MUST be shared state. The Redis-backed impl lives in
buzz-pubsub as RedisNip98ReplayGuard and uses a single SET key 1 NX
EX <ttl> per claim.
Key shape: buzz:{community}:nip98:{event_id_hex}. Event ids are
content-addressed so natural cross-community collision is zero, but
the gate is fail-closed isolation — a same-id replay across
communities must consult two distinct seen-set rows, not one shared
row. Tests pin both the prefix and the cross-community isolation
guarantee.
TTL floor is DEFAULT_REPLAY_TTL_SECS = 120, matching the §5 gate
requirement and the doubled NIP-98 ±60s timestamp tolerance.
Implementations MAY clamp sub-floor TTLs up to the floor; they MUST
NOT honor smaller values. The Redis impl clamps.
Caller contract documented in the trait: verify first, then mark.
Burning a seen-set slot on a forgery would let an attacker who learns
a future event id DoS the legitimate event. On Err (Redis
unreachable) callers MUST fail closed.
Not wired into a call site in this commit — there is no NIP-98 HTTP
handler in Lane 0 yet. Eva's relay-wiring lane will consume the trait
when the HTTP path lands; the contract is documented for that
integration.
Validation:
- cargo test -p buzz-auth --lib ✅ 40 passed (4 new in nip98_replay).
- cargo test -p buzz-pubsub --lib ✅ 3 passed, 9 Redis-required
ignored (3 new in nip98_replay).
- cargo test -p buzz-pubsub --lib nip98_replay -- --ignored against
local Redis ✅ 3 passed: first-claim/replay, cross-community
isolation, sub-floor TTL lifted to floor.
- Workspace check not run locally (sqlx 0.9.0 / rustc 1.94 vs local
1.89); CI catches it.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
RateLimiter::check_and_increment now takes &TenantContext, and
rate_limit_key emits buzz:{community}:ratelimit:{pubkey_hex}:{suffix}.
Same pubkey active in two communities consumes two independent quotas,
matching the S1 cross-community isolation fence in the buzz-relay
rewrite spec.
check_ip_connection stays operator-global by design. The IP fence runs
at connection acceptance, before host->community resolution has
completed (or, on resolve failure, instead of it). Threading
&TenantContext through it would invert the order of operations. Per-
(community, IP) caps, if ever needed as a tenant-fairness signal,
belong in an additive LimitType keyed on (community, ip) — not in this
trait.
RedisRateLimiter in buzz-pubsub follows the new trait signature.
AlwaysAllowRateLimiter test impl mirrors it. Two new tests pin the
behavior: the key includes the community prefix, and same-pubkey-two-
communities yields two distinct Redis keys.
Local cargo test -p buzz-auth: 36 passed. Local cargo test -p
buzz-pubsub: 3 passed, 6 Redis-required ignored. Workspace-wide check
not run locally (sqlx 0.9.0 requires rustc 1.94, local toolchain is
1.89 — same constraint Max hit on the pubsub lane); relying on CI for
the full integration compile.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Resolve the community from the connection host BEFORE the WebSocket upgrade
(conformance row-zero) and carry it as ConnectionState.tenant for the whole
connection lifetime. Handlers read &conn.tenant and pass it into scoped
DB/pub-sub calls; nothing downstream can mint or change it.
- RelayError::HostNotMapped: fail-closed, generic message (no host oracle).
- normalize_host(&HeaderMap, fallback): request Host header, lowercased,
port-stripped; falls back to the configured relay_url host (N=1 parity).
- AppState::resolve_tenant: the ONLY mint site, via the buzz-db lookup seam.
Integration seam: depends on buzz-db Db::lookup_community_by_host (Mari's lane).
Until that lands on rebase, the crate has exactly that one unresolved symbol;
everything else (normalize_host + tests, the field plumbing) compiles clean.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
buzz-core gets the zero-I/O tenant identity types every scoped layer
shares. TenantContext encodes conformance row-zero in the type system:
no Default, no Deserialize, no public constructor except resolved(),
which is meant to be called only from host resolution. Downstream code
holds &TenantContext and can read but not mint a community, so
client-chosen-community cannot type-check outside resolution.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>