buzz-relay: bind NIP-42 AUTH relay tag to per-tenant host (row 44 sibling)

NIP-42 sibling of the NIP-98 host-binding fix in be9d26e55. `handle_auth` was verifying the AUTH event's `relay` tag against `state.config.relay_url` (one static string per deployment), so under multi-tenant:

  (a) An AUTH event signed against community A's host could be accepted on a connection whose tenant resolved to community B (cross-host token reuse — the same hole `nip98_expected_url` closed on the HTTP side).

  (b) Every legitimate connection whose tenant host wasn't the single configured one would be rejected (the wall Quinn hit bringing up `search_fts`'s two-host harness).

Add `nip42_expected_relay_url(config_relay_url, &tenant)` next to `nip98_expected_url` in `bridge.rs` — scheme from config (preserves `ws://`/`wss://` TLS posture), host from `tenant.host()` (request-resolved, never client-supplied). Thread it at `handlers/auth.rs:73` so `verify_auth_event` receives the per-tenant URL.

Tests (mirror `nip98_expected_url_*` shape, `bridge.rs:1303-1432`):

  * `verify_nip42_rejects_event_signed_for_wrong_communitys_host` — attacker on B-bound connection signs AUTH matching `config.relay_url` (=A's host); fix rejects with `RelayUrlMismatch`. Bites the exact "reverted to config host" regression.

  * `verify_nip42_accepts_event_signed_for_matching_host` — positive control: matching-host AUTH verifies.

  * `nip42_expected_relay_url_uses_tenant_host_not_config_host` — pins host-from-tenant in both directions.

  * `nip42_expected_relay_url_derives_scheme_from_config` — pins `ws://` ↔ `wss://` scheme passthrough.

Plus a live two-host integration test (`crates/buzz-test-client/tests/nip42_host_binding_live.rs`, `#[ignore]`): two seeded communities at `a.localhost:3100`/`b.localhost:3100`, raw WS AUTH with forged `relay` tag, four cases. Under the pre-fix mutation (`config.relay_url` verbatim) with `RELAY_URL=ws://a.localhost:3100`: `nip42_matching_host_accepted_b` failed (`auth-required: verification failed` — legit B traffic blocked) and `nip42_cross_host_rejected_a_relay_tag_on_b_connection` failed (relay accepted A-tag on B connection — the hole). Both green after restore.

Bar:

  * `cargo test -p buzz-relay -p buzz-auth -- --test-threads=1`: buzz-auth 45/45, buzz-relay 403/403 + 1 integration.

  * `cargo test -p buzz-test-client --test nip42_host_binding_live -- --ignored`: 4/4 against live two-host relay.

  * `cargo clippy -p buzz-relay -p buzz-auth -p buzz-test-client --tests -- -D warnings`: clean.

  * Mutate-bite (helper body → `config_relay_url.to_string()`): unit tests 3 RED, live tests 2 RED — exact pre-fix bug shape — both restored byte-identical to green.

Base: 4b6e1e43d (PR #1321 tip). Row-zero priority — gates every WS-authed conformance matrix row.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-06-27 08:21:18 -04:00
committed by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent 7469194042
commit 0eddf10345
3 changed files with 305 additions and 1 deletions

View File

@@ -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<uuid::Uuid> {
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(

View File

@@ -70,7 +70,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, 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);

View File

@@ -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}"
);
}