diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 005609d623..64cbac1d1c 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -144,6 +144,32 @@ fn nip98_expected_url(config_relay_url: &str, tenant: &TenantContext, path: &str format!("{scheme}://{}{path}", tenant.host()) } +/// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`. +/// +/// NIP-42 (WebSocket AUTH) sibling of [`nip98_expected_url`]. Conformance row 44 +/// obligation extends to the WS auth side: the AUTH event's `relay` tag must +/// match the per-tenant host the connection arrived on, not the deployment-wide +/// `config.relay_url`. Same hole the NIP-98 fix closed for HTTP — `config.relay_url` +/// is one static string per deployment, so verifying against it (a) admits an +/// AUTH event signed against community A's host on a connection bound to +/// community B (cross-host token reuse), and (b) rejects every legitimate AUTH +/// whose tenant host isn't the single configured one. +/// +/// Scheme is `ws`/`wss` (not `http`/`https`) because the value being matched is +/// the client's connect URL embedded in the signed AUTH event; the helper +/// preserves the deployment's TLS posture from `config_relay_url`'s prefix so +/// `wss://` deployments stay `wss://` and `ws://` dev/test stays `ws://`. +/// Path is empty — clients put the bare WS origin (`ws://host[:port]`) in the +/// `relay` tag, matching how `EventBuilder::auth` accepts a [`nostr::RelayUrl`]. +pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String { + let scheme = if config_relay_url.trim_start().starts_with("wss://") { + "wss" + } else { + "ws" + }; + format!("{scheme}://{}", tenant.host()) +} + /// Extract a channel UUID from a single filter's `#h` tag. fn extract_channel_from_filter(filter: &nostr::Filter) -> Option { let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); @@ -1405,6 +1431,124 @@ mod tests { ); } + // ----- NIP-42 host-binding tests (sibling of NIP-98 row 44 obligation) ----- + + /// Sign a NIP-42 AUTH event with `relay` tag = `relay_url`, then verify + /// it against `expected_relay_url`. Returns the `verify_nip42_event` result. + fn verify_nip42_with_urls( + challenge: &str, + signed_relay_url: &str, + expected_relay_url: &str, + ) -> Result<(), buzz_auth::AuthError> { + let keys = Keys::generate(); + let parsed = nostr::RelayUrl::parse(signed_relay_url).expect("valid relay url"); + let event = EventBuilder::auth(challenge, parsed) + .sign_with_keys(&keys) + .expect("sign auth event"); + buzz_auth::nip42::verify_nip42_event(&event, challenge, expected_relay_url) + } + + /// Row 44 obligation (WS side): a NIP-42 AUTH event signed against + /// community A's host MUST be rejected on a connection whose tenant + /// resolved to community B's host. Before this gap closed, `handle_auth` + /// verified against `state.config.relay_url` (one static string per + /// deployment), so a token-of-A presented on B's connection would pass — + /// the cross-host hole `nip98_expected_url` already closed on the HTTP + /// side, mirrored here on the WS side. + /// + /// Scenario: a multi-tenant deployment whose `config.relay_url` is set to + /// community A's host (a realistic accident — config can only hold one + /// host). An attacker on a B-bound connection signs an AUTH event matching + /// that config URL (publicly knowable). Pre-fix: expected = config = A's + /// URL = the signed URL → ACCEPT (cross-host hole). Post-fix: expected + /// derives from `tenant.host() = B` ≠ signed (A) → REJECT. + /// + /// This test bites if `nip42_expected_relay_url` is reverted to return + /// `config.relay_url` verbatim — the exact regression the helper guards. + #[test] + fn verify_nip42_rejects_event_signed_for_wrong_communitys_host() { + let challenge = "fixed-challenge-for-test"; + // Config URL is A's host (deployment-wide static), and the attacker + // signs an AUTH event with that same URL. Both are knowable to the + // attacker. Connection arrived at community B. + let config_relay_url = "ws://host-a.example:3100"; + let signed_relay_url = "ws://host-a.example:3100"; + let tenant_b = fresh_tenant("host-b.example:3100"); + let expected = nip42_expected_relay_url(config_relay_url, &tenant_b); + + let err = verify_nip42_with_urls(challenge, signed_relay_url, &expected).expect_err( + "cross-host NIP-42 AUTH event MUST be rejected — row 44 sibling: \ + `relay` URL host must match the per-tenant host, NOT the \ + deployment-wide config URL", + ); + assert!( + matches!(err, buzz_auth::AuthError::RelayUrlMismatch), + "rejection must carry RelayUrlMismatch (not a generic failure) so \ + callers can distinguish it from other auth failures; got {err:?}" + ); + } + + /// Positive control: a NIP-42 AUTH event signed for host A MUST be + /// accepted on a connection whose tenant resolved to host A. Without + /// this, the cross-host test could be passing vacuously (e.g. if + /// `nip42_expected_relay_url` always produced a URL no event could match). + #[test] + fn verify_nip42_accepts_event_signed_for_matching_host() { + let challenge = "fixed-challenge-for-test"; + let signed_relay_url = "ws://host-a.example:3100"; + // Configured relay URL deliberately differs in host from the tenant's + // host — proving the helper uses `tenant.host()`, not the config. + let config_relay_url = "ws://other-config-host.example"; + let tenant_a = fresh_tenant("host-a.example:3100"); + let expected = nip42_expected_relay_url(config_relay_url, &tenant_a); + + verify_nip42_with_urls(challenge, signed_relay_url, &expected) + .expect("matching-host NIP-42 AUTH event must verify"); + } + + /// `nip42_expected_relay_url` derives host from `tenant`, not from + /// `config_relay_url`. Pin both directions: changing the tenant's host + /// changes the output; changing the config's host does NOT. + #[test] + fn nip42_expected_relay_url_uses_tenant_host_not_config_host() { + let tenant_a = fresh_tenant("host-a.example:3100"); + let tenant_b = fresh_tenant("host-b.example:3100"); + + let url_a = nip42_expected_relay_url("ws://config-host.example", &tenant_a); + let url_b = nip42_expected_relay_url("ws://config-host.example", &tenant_b); + assert_eq!(url_a, "ws://host-a.example:3100"); + assert_eq!(url_b, "ws://host-b.example:3100"); + + // Same tenant, two different config hosts → output is identical. + // (If config-host ever leaked into the URL, this assertion would bite — + // catches the exact "reverted to config host" regression.) + let url_a_alt_config = nip42_expected_relay_url("ws://different-config.example", &tenant_a); + assert_eq!( + url_a, url_a_alt_config, + "config-relay-url's host MUST NOT influence the NIP-42 expected URL — \ + only its scheme contributes" + ); + } + + /// `nip42_expected_relay_url` derives scheme from `config_relay_url`'s + /// prefix: `wss://` → `wss`, everything else → `ws`. Deployments that run + /// `ws://` in dev/test must produce a `ws://` URL that matches what + /// tungstenite clients put in the AUTH event's `relay` tag. + #[test] + fn nip42_expected_relay_url_derives_scheme_from_config() { + let tenant = fresh_tenant("host-a.example:3100"); + assert_eq!( + nip42_expected_relay_url("wss://config.example", &tenant), + "wss://host-a.example:3100", + "wss:// production config → wss:// URL" + ); + assert_eq!( + nip42_expected_relay_url("ws://config.example", &tenant), + "ws://host-a.example:3100", + "ws:// dev config → ws:// URL" + ); + } + /// Build a kind:30174 engram envelope authored by `agent`, tagged with `owner`. fn engram_envelope(agent: &Keys, owner_hex: &str) -> buzz_core::StoredEvent { let d_tag = Tag::custom( diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 939a5c8342..ef17426f67 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -70,7 +70,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); - let relay_url = state.config.relay_url.clone(); + let relay_url = + crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); let auth_svc = Arc::clone(&state.auth); metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1); diff --git a/crates/buzz-test-client/tests/nip42_host_binding_live.rs b/crates/buzz-test-client/tests/nip42_host_binding_live.rs new file mode 100644 index 0000000000..3bcfda2b1d --- /dev/null +++ b/crates/buzz-test-client/tests/nip42_host_binding_live.rs @@ -0,0 +1,159 @@ +//! Live two-host proof for the NIP-42 host-binding fix. +//! +//! Sibling of the NIP-98 row 44 obligation: AUTH event's `relay` tag must match +//! the per-tenant host of the connection it arrives on, not the deployment-wide +//! `config.relay_url`. +//! +//! Requires a running multi-tenant relay with TWO seeded communities. Bring-up: +//! +//! ```sh +//! # Compose up infra, schema, then seed: +//! INSERT INTO communities (id, host) VALUES +//! ('11111111-1111-4111-8111-111111111111', 'a.localhost:3100'), +//! ('22222222-2222-4222-8222-222222222222', 'b.localhost:3100'); +//! # Run one binary, BUZZ_HEALTH_PORT=8180 BUZZ_METRICS_PORT=9202, +//! # BUZZ_RECONCILE_CHANNELS=false, BUZZ_GIT_CONFORMANCE_PROBE=false +//! ``` +//! +//! Each test is `#[ignore]` so it only runs explicitly: +//! `cargo test -p buzz-test-client --test nip42_host_binding_live -- --ignored --test-threads=1` + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use nostr::{EventBuilder, Keys, RelayUrl}; +use serde_json::{json, Value}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +const HOST_A: &str = "ws://a.localhost:3100"; +const HOST_B: &str = "ws://b.localhost:3100"; + +/// Connect, wait for AUTH challenge, send a kind:22242 with `relay_tag_url`, +/// return the OK response's accepted flag and message. +async fn do_auth_with_relay_tag( + connect_url: &str, + relay_tag_url: &str, +) -> Result<(bool, String), String> { + let (mut ws, _) = connect_async(connect_url) + .await + .map_err(|e| format!("connect: {e}"))?; + + // Wait for AUTH challenge. + let challenge = loop { + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .map_err(|_| "timeout waiting for AUTH challenge".to_string())? + .ok_or_else(|| "ws closed before challenge".to_string())? + .map_err(|e| format!("ws read: {e}"))?; + let text = match msg { + Message::Text(t) => t, + Message::Binary(_) | Message::Ping(_) | Message::Pong(_) => continue, + other => return Err(format!("unexpected ws frame: {other:?}")), + }; + let v: Value = serde_json::from_str(&text).map_err(|e| format!("json: {e}"))?; + if v.get(0).and_then(|s| s.as_str()) == Some("AUTH") { + break v + .get(1) + .and_then(|s| s.as_str()) + .ok_or_else(|| "AUTH msg missing challenge".to_string())? + .to_string(); + } + // ignore NOTICE etc. + }; + + // Sign a NIP-42 AUTH event with chosen relay tag. + let keys = Keys::generate(); + let parsed: RelayUrl = relay_tag_url + .parse() + .map_err(|e| format!("parse relay tag url {relay_tag_url}: {e}"))?; + let event = EventBuilder::auth(&challenge, parsed) + .sign_with_keys(&keys) + .map_err(|e| format!("sign: {e}"))?; + let event_id_hex = event.id.to_hex(); + + let send = json!(["AUTH", event]); + ws.send(Message::Text(send.to_string().into())) + .await + .map_err(|e| format!("ws send: {e}"))?; + + // Wait for OK with matching event id. + loop { + let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) + .await + .map_err(|_| "timeout waiting for OK".to_string())? + .ok_or_else(|| "ws closed before OK".to_string())? + .map_err(|e| format!("ws read OK: {e}"))?; + let text = match msg { + Message::Text(t) => t, + _ => continue, + }; + let v: Value = serde_json::from_str(&text).map_err(|e| format!("json OK: {e}"))?; + if v.get(0).and_then(|s| s.as_str()) == Some("OK") + && v.get(1).and_then(|s| s.as_str()) == Some(&event_id_hex) + { + let accepted = v.get(2).and_then(|s| s.as_bool()).unwrap_or(false); + let message = v.get(3).and_then(|s| s.as_str()).unwrap_or("").to_string(); + return Ok((accepted, message)); + } + } +} + +/// AUTH event signed for host A's URL on a connection to host A → ACCEPT. +#[tokio::test] +#[ignore = "requires two-host multi-tenant relay"] +async fn nip42_matching_host_accepted_a() { + let (accepted, msg) = do_auth_with_relay_tag(HOST_A, HOST_A) + .await + .expect("auth flow on host A"); + assert!( + accepted, + "matching-host AUTH on host A must be ACCEPTED; relay said: {msg}" + ); +} + +/// AUTH event signed for host B's URL on a connection to host B → ACCEPT. +#[tokio::test] +#[ignore = "requires two-host multi-tenant relay"] +async fn nip42_matching_host_accepted_b() { + let (accepted, msg) = do_auth_with_relay_tag(HOST_B, HOST_B) + .await + .expect("auth flow on host B"); + assert!( + accepted, + "matching-host AUTH on host B must be ACCEPTED; relay said: {msg}" + ); +} + +/// Cross-host attack on the B-bound connection: forge AUTH with `relay` tag +/// pointing at host A. Pre-fix this passed (verified against +/// `state.config.relay_url`). Post-fix the per-tenant host check rejects it. +#[tokio::test] +#[ignore = "requires two-host multi-tenant relay"] +async fn nip42_cross_host_rejected_a_relay_tag_on_b_connection() { + let (accepted, msg) = do_auth_with_relay_tag(HOST_B, HOST_A) + .await + .expect("auth flow with cross-host relay tag"); + assert!( + !accepted, + "cross-host AUTH (relay-tag=A on connection=B) must be REJECTED; relay said: {msg}" + ); + // Must be the host-binding rejection, not some other error. + assert!( + msg.contains("auth-required") || msg.contains("verification"), + "rejection must be the NIP-42 verification-failure signal; relay said: {msg}" + ); +} + +/// Mirror of the cross-host test in the opposite direction: B-relay-tag on +/// A-connection → REJECT. +#[tokio::test] +#[ignore = "requires two-host multi-tenant relay"] +async fn nip42_cross_host_rejected_b_relay_tag_on_a_connection() { + let (accepted, msg) = do_auth_with_relay_tag(HOST_A, HOST_B) + .await + .expect("auth flow with mirror cross-host relay tag"); + assert!( + !accepted, + "cross-host AUTH (relay-tag=B on connection=A) must be REJECTED; relay said: {msg}" + ); +}