From 41602ab4d283afdfd9167d5c7d17fea8259806ac Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 13:41:00 -0400 Subject: [PATCH] =?UTF-8?q?docs(auth):=20annotate=20AuthError=20variants?= =?UTF-8?q?=20with=20WIRE=20class=20=E2=80=94=20doc-at-source=20for=20P1+P?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Tyler Longwell --- crates/buzz-auth/src/error.rs | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/buzz-auth/src/error.rs b/crates/buzz-auth/src/error.rs index 7f8131bc3..b299927d3 100644 --- a/crates/buzz-auth/src/error.rs +++ b/crates/buzz-auth/src/error.rs @@ -5,21 +5,53 @@ /// Variants are designed to be safe to return to callers without leaking /// internal implementation details. Do **not** include raw token values, /// database contents, or stack traces in error messages. +/// +/// # Wire-mapping contract +/// +/// This enum describes **what happened**; how it renders on the wire is the +/// relay's concern. The single sanctioned conversion lives in +/// `buzz_relay::auth_wire::auth_error_wire`, which collapses every variant +/// into one of four `AuthErrorWireCategory` values: +/// +/// - `AuthFailed` — all verification-class variants (`InvalidSignature`, +/// `ChallengeMismatch`, `RelayUrlMismatch`, `EventExpired`, `Nip98Invalid`, +/// `Nip98Replay`, `PubkeyMismatch`) MUST be byte-indistinguishable on the +/// wire. Distinguishing any pair turns the community-scoped replay seen-set +/// or membership state into a presence oracle. See the audit note at +/// `RESEARCH/RELAY_REWRITE_AUTH_ERROR_ORACLE_AUDIT.md` (policies P1–P5). +/// - `InsufficientScope` / `ChannelAccessDenied` — authorization class, +/// remediation-distinct, carry no tenant-scoped detail. +/// - `InternalRedacted` — `Internal(_)` MUST NEVER stringify on the wire; the +/// inner string can carry community-prefixed Redis keys (existence oracle). +/// The construction site logs detail via `tracing::warn!`; the wire sees +/// the category only. +/// +/// The exhaustive match in `auth_error_wire` (no wildcard arm) is the +/// compile-time fence: adding any variant here fails to compile in +/// `buzz-relay` until the wire-class decision is made. #[derive(Debug, thiserror::Error)] pub enum AuthError { /// The NIP-42 event signature is invalid or the event is structurally malformed. + /// + /// **WIRE class:** `AuthFailed`. Byte-identical with all other verification-class variants. #[error("invalid signature or malformed auth event")] InvalidSignature, /// The `challenge` tag in the AUTH event does not match the relay's issued challenge. + /// + /// **WIRE class:** `AuthFailed`. Byte-identical with all other verification-class variants. #[error("challenge mismatch")] ChallengeMismatch, /// The `relay` tag in the AUTH event does not match this relay's URL. + /// + /// **WIRE class:** `AuthFailed`. Byte-identical with all other verification-class variants. #[error("relay url mismatch")] RelayUrlMismatch, /// The AUTH event's `created_at` timestamp is more than ±60 seconds from now. + /// + /// **WIRE class:** `AuthFailed`. Byte-identical with all other verification-class variants. #[error("auth event timestamp outside ±60s window")] EventExpired, @@ -27,20 +59,32 @@ pub enum AuthError { /// /// The inner string describes the specific failure (signature, timestamp, URL, etc.) /// and is safe to include in server logs. Do **not** forward raw event content to clients. + /// + /// **WIRE class:** `AuthFailed`. Byte-identical with all other verification-class variants; + /// the inner string is log-only. #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), /// A NIP-98 event with the same id has already been observed within the /// replay-prevention window. The event itself was structurally valid; the /// rejection is on freshness, not validity. + /// + /// **WIRE class:** `AuthFailed`. MUST be byte-indistinguishable from + /// `Nip98Invalid` on the wire — distinguishing them turns the + /// community-scoped seen-set into a presence oracle on event ids. #[error("NIP-98 replay: event id already seen within window")] Nip98Replay, /// The pubkey in the auth event does not match the expected identity. + /// + /// **WIRE class:** `AuthFailed`. Byte-identical with all other verification-class variants. #[error("pubkey mismatch: event pubkey does not match authenticated identity")] PubkeyMismatch, /// The authenticated context does not have the required scope for this operation. + /// + /// **WIRE class:** `InsufficientScope`. Authorization-class, remediation-distinct; + /// the `required`/`have` fields carry no tenant-scoped detail and are safe on the wire. #[error("insufficient scope: required {required}, have {have:?}")] InsufficientScope { /// The scope that was required. @@ -50,10 +94,17 @@ pub enum AuthError { }, /// The authenticated user is not a member of the requested channel. + /// + /// **WIRE class:** `ChannelAccessDenied`. Authorization-class, remediation-distinct. #[error("channel access denied")] ChannelAccessDenied, /// An unexpected internal error occurred (e.g. a `spawn_blocking` panic). + /// + /// **WIRE class:** `InternalRedacted`. The inner string MUST NEVER appear on the wire — + /// it can carry community-prefixed Redis keys, downstream error chains, or other + /// existence-oracle surfaces. Log detail at the construction site; the wire sees the + /// category only. #[error("internal auth error: {0}")] Internal(String), }