Files
buzz/crates/buzz-test-client/tests/nip42_host_binding_live.rs
Tyler 14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00

160 lines
6.0 KiB
Rust

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