Files
buzz/crates
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm 7fc43fb391 audit(relay): collapse AuthError to category-only on wire — close existence-oracle
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>
2026-06-26 13:34:03 -04:00
..