diff --git a/crates/buzz-pair-relay/src/lib.rs b/crates/buzz-pair-relay/src/lib.rs index 8d30a4b1ec4..1706cf0659c 100644 --- a/crates/buzz-pair-relay/src/lib.rs +++ b/crates/buzz-pair-relay/src/lib.rs @@ -9,18 +9,22 @@ //! This binary binds **loopback only** and MUST run behind a reverse proxy //! (nginx, caddy, etc.) that: //! - Routes only `/pair` to this sidecar -//! - Enforces HTTP read timeouts (mitigates slowloris at the TCP layer) +//! - Keeps the pre-upgrade HTTP header/request timeout short and applies +//! connection/rate limits before forwarding to the sidecar +//! - Keeps post-upgrade WebSocket read/send/idle timeouts **greater than 140 +//! seconds** so the proxy cannot preempt [`CONN_TIMEOUT`] //! - Terminates TLS //! -//! The relay does not enforce path restrictions or pre-upgrade connection -//! limits — those are the reverse proxy's responsibility. +//! The relay independently enforces [`HTTP_HEADER_READ_TIMEOUT`]. Path +//! restrictions and pre-upgrade connection/rate limits remain the reverse +//! proxy's responsibility. //! //! # Security Model //! //! - **Signature verification** — Schnorr signatures are verified against the //! NIP-01 event ID hash. Events with invalid signatures are rejected. //! - **No persistence** — events exist only in-flight between matched pub/sub. -//! - **Bounded resources** — 128 max WS connections, 4 KiB max frame, 120s TTL. +//! - **Bounded resources** — 128 max WS connections, 4 KiB max frame, 140s TTL. //! - **Session cap** — at most 6 accepted EVENTs per connection. //! - **Freshness** — `created_at` must be within ±120 s of relay wall-clock. //! - **Deduplication** — duplicate event IDs are rejected; dedup entries expire after 300 s. @@ -41,7 +45,7 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::upgrade::Upgraded; use hyper::{Method, Request, Response, StatusCode, Version}; -use hyper_util::rt::TokioIo; +use hyper_util::rt::{TokioIo, TokioTimer}; use parking_lot::Mutex; use secp256k1::schnorr::Signature as SchnorrSig; use secp256k1::XOnlyPublicKey; @@ -55,8 +59,20 @@ use tokio_tungstenite::tungstenite::protocol::{Message, Role, WebSocketConfig}; use tokio_tungstenite::WebSocketStream; use tokio_util::sync::CancellationToken; -/// Hard per-connection lifetime. `pub(crate)` for test access. -pub(crate) const CONN_TIMEOUT: Duration = Duration::from_secs(120); +/// Hard per-connection lifetime. +/// +/// Must outlive desktop `PAIRING_HARD_TIMEOUT` in +/// `desktop/src-tauri/src/commands/pairing.rs`. If this sidecar closes first, +/// the desktop surfaces the transport string "relay connection closed" instead +/// of its own "Session timed out" expired state. +pub const CONN_TIMEOUT: Duration = Duration::from_secs(140); + +/// Maximum time allowed to receive a complete pre-upgrade HTTP header. +/// +/// This is intentionally much shorter than [`CONN_TIMEOUT`]: ordinary HTTP +/// parsing is the slowloris boundary, while the longer timeout applies only +/// after a valid WebSocket upgrade has reserved a bounded relay slot. +pub const HTTP_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10); const MAX_CONNS: u32 = 128; const CHANNEL_CAP: usize = 4; @@ -128,6 +144,12 @@ impl Relay { } } + /// Number of connections that completed a valid WebSocket upgrade and + /// currently hold one of the bounded relay slots. + pub fn active_connection_count(&self) -> u32 { + self.conn_count.load(Ordering::Relaxed) + } + /// Atomically check-and-reserve an event ID. Evicts expired entries first. /// Returns `Ok(true)` if duplicate (already seen), `Ok(false)` if new /// (reserved — caller MUST call `unreserve_id` if delivery fails), @@ -1013,11 +1035,10 @@ pub async fn run_server(listener: TcpListener, relay: Arc) { tokio::spawn(async move { let io = TokioIo::new(tcp); let svc = service_fn(move |req| http_service(Arc::clone(&relay), req)); - if let Err(e) = http1::Builder::new() - .serve_connection(io, svc) - .with_upgrades() - .await - { + let mut http = http1::Builder::new(); + http.timer(TokioTimer::new()) + .header_read_timeout(HTTP_HEADER_READ_TIMEOUT); + if let Err(e) = http.serve_connection(io, svc).with_upgrades().await { eprintln!("http error: {e}"); } }); diff --git a/crates/buzz-pair-relay/tests/integration.rs b/crates/buzz-pair-relay/tests/integration.rs index 77b71ba4dd5..ddf7ef11ae9 100644 --- a/crates/buzz-pair-relay/tests/integration.rs +++ b/crates/buzz-pair-relay/tests/integration.rs @@ -6,12 +6,13 @@ use std::sync::Arc; use std::time::Duration; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{FutureExt, SinkExt, StreamExt}; use serde_json::{json, Value}; -use tokio::net::TcpListener; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; -use buzz_pair_relay::{run_server, Relay}; +use buzz_pair_relay::{run_server, Relay, CONN_TIMEOUT}; use secp256k1::{Keypair, Secp256k1, SecretKey}; use sha2::{Digest, Sha256}; @@ -32,11 +33,28 @@ type WS = WebSocketStream>; /// Start a relay on a random port, return the WebSocket URL. async fn start_relay() -> String { + start_relay_with_state().await.0 +} + +/// Start a relay and retain its state for resource-accounting assertions. +async fn start_relay_with_state() -> (String, Arc) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let relay = Arc::new(Relay::new()); - tokio::spawn(run_server(listener, relay)); - format!("ws://127.0.0.1:{}", addr.port()) + tokio::spawn(run_server(listener, Arc::clone(&relay))); + (format!("ws://127.0.0.1:{}", addr.port()), relay) +} + +/// Wait for connection cleanup to reach an observable state before asserting +/// client-side EOF in virtual-time tests. +async fn wait_for_active_connections(relay: &Relay, expected: u32) { + for _ in 0..50 { + if relay.active_connection_count() == expected { + return; + } + tokio::task::yield_now().await; + } + assert_eq!(relay.active_connection_count(), expected); } /// Connect a WebSocket client to the relay. @@ -86,6 +104,21 @@ async fn assert_closed(ws: &mut WS) { } } +/// Assert closure without a virtual-time timeout, which can elapse before the +/// detached WebSocket writer observes cancellation. +async fn assert_closed_after_yields(ws: &mut WS) { + for _ in 0..100 { + if let Some(result) = ws.next().now_or_never() { + match result { + None | Some(Ok(Message::Close(_))) | Some(Err(_)) => return, + Some(Ok(other)) => panic!("expected close, got {other:?}"), + } + } + tokio::task::yield_now().await; + } + panic!("connection did not close after server cleanup"); +} + /// Generate a random keypair; returns `(SecretKey, pubkey_hex)`. fn gen_keypair() -> (SecretKey, String) { let secp = Secp256k1::new(); @@ -438,18 +471,17 @@ async fn test_second_sub_same_id() { assert_eq!(ev_msg[1], "s1"); } -/// 9. Connection closes after 120 s (virtual time). +/// 9. Connection closes after CONN_TIMEOUT (virtual time). #[tokio::test(start_paused = true)] -async fn test_120s_timeout() { - let url = start_relay().await; +async fn test_conn_timeout() { + let (url, relay) = start_relay_with_state().await; let mut ws = connect(&url).await; // Advance virtual time past the connection timeout. - tokio::time::advance(Duration::from_secs(121)).await; - // Yield to let the relay task run its deadline branch. - tokio::task::yield_now().await; + tokio::time::advance(CONN_TIMEOUT + Duration::from_secs(1)).await; + wait_for_active_connections(&relay, 0).await; - assert_closed(&mut ws).await; + assert_closed_after_yields(&mut ws).await; } /// 10. Backpressure unit test: bounded mpsc channel rejects when full. @@ -605,6 +637,37 @@ async fn test_global_conn_cap() { let _new = connect(&url).await; } +/// An incomplete pre-upgrade request is bounded independently of the longer +/// WebSocket lifetime and never consumes one of the 128 upgraded slots. +#[tokio::test] +async fn test_incomplete_http_header_times_out_before_ws_pool() { + const HEADER_CLOSE_DEADLINE: Duration = Duration::from_secs(12); + + let (url, relay) = start_relay_with_state().await; + let addr = url.strip_prefix("ws://").unwrap(); + let mut slow = TcpStream::connect(addr).await.unwrap(); + slow.write_all(b"GET /pair HTTP/1.1\r\nHost: localhost\r\n") + .await + .unwrap(); + + // The incomplete request has not upgraded and therefore must not consume + // any of the bounded WebSocket pool. + tokio::task::yield_now().await; + assert_eq!(relay.active_connection_count(), 0); + + let mut byte = [0_u8; 1]; + let read = tokio::time::timeout(HEADER_CLOSE_DEADLINE, slow.read(&mut byte)).await; + assert!( + matches!(read, Ok(Ok(0)) | Ok(Err(_))), + "incomplete HTTP header remained open after {HEADER_CLOSE_DEADLINE:?}: {read:?}" + ); + + // A valid upgrade still acquires the first pool slot after the slow + // request is discarded. + let _ws = connect(&url).await; + assert_eq!(relay.active_connection_count(), 1); +} + /// 18. Session event cap: 6 EVENTs are accepted; 7th is rejected with /// "session event limit reached". (The relay's hard cap is 6 per /// connection, which is tighter than the per-window rate limit of 10.) @@ -1176,18 +1239,18 @@ async fn test_reader_backpressure_closes() { } } -/// 42. Connection closes promptly after 120 s (virtual time). +/// 42. Connection closes promptly after CONN_TIMEOUT (virtual time). /// Explicit duplicate of test 9 with a slightly different assertion style. #[tokio::test(start_paused = true)] async fn test_cancellation_immediate() { - let url = start_relay().await; + let (url, relay) = start_relay_with_state().await; let mut ws = connect(&url).await; - tokio::time::advance(Duration::from_secs(121)).await; - tokio::task::yield_now().await; + tokio::time::advance(CONN_TIMEOUT + Duration::from_secs(1)).await; + wait_for_active_connections(&relay, 0).await; // The connection must be closed — not just slow. - assert_closed(&mut ws).await; + assert_closed_after_yields(&mut ws).await; } /// 43. Client-initiated graceful close receives a Close reply. diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 86989676604..cf7e3a01909 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -167,6 +167,24 @@ clients connect directly to the dedicated endpoint. The chart does not create an Ingress or HTTPRoute for the pairing Service; route the public hostname to `-buzz-pairing:5000` with your platform's ingress configuration. +Configure two distinct timeout classes on the public ingress/proxy: + +- Keep the **pre-upgrade HTTP header/request timeout short** and apply + connection/rate limits before traffic reaches the sidecar. The sidecar also + closes incomplete HTTP headers after 10 seconds, but the public proxy is the + first resource-exhaustion boundary. +- Set **post-upgrade WebSocket read/send/idle timeouts greater than 140 + seconds** (for example, 150 or 180 seconds). This lets Desktop own the + user-visible 130-second expiry and keeps the sidecar's 140-second timeout as + the orphaned-client safety net. + +For ingress-nginx, set `nginx.ingress.kubernetes.io/proxy-read-timeout` and +`nginx.ingress.kubernetes.io/proxy-send-timeout` to `150` or higher on the +pairing Ingress while keeping the controller's `client-header-timeout` short. +For an AWS ALB, set `idle_timeout.timeout_seconds` above 140; for a Google +Cloud BackendService, set `timeoutSec` above 140. Do not raise the ordinary +HTTP header/request timeout to match the WebSocket lifetime. + ## HA (production) `replicaCount > 1` hard-requires Redis: diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5bd19ef2b6d..9d783d8284f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1093,6 +1093,7 @@ dependencies = [ "buzz-agent", "buzz-core", "buzz-media", + "buzz-pair-relay", "buzz-persona", "buzz-sdk", "buzz-terminal", @@ -1204,6 +1205,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-pair-relay" +version = "0.1.0" +dependencies = [ + "futures-util", + "http-body-util", + "hyper", + "hyper-util", + "parking_lot", + "secp256k1 0.31.1", + "serde_json", + "sha2 0.11.0", + "tokio", + "tokio-tungstenite 0.29.0", + "tokio-util", +] + [[package]] name = "buzz-persona" version = "0.1.0" @@ -1639,7 +1657,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2306,7 +2324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2484,7 +2502,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2765,7 +2783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3283,7 +3301,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -5741,7 +5759,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5900,7 +5918,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6413,7 +6431,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7117,7 +7135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -8143,7 +8161,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8874,7 +8892,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8944,7 +8962,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9125,7 +9143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "rand 0.8.6", - "secp256k1-sys", + "secp256k1-sys 0.10.1", "serde", ] @@ -9137,7 +9155,18 @@ checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes 0.14.101", "rand 0.8.6", - "secp256k1-sys", + "secp256k1-sys 0.10.1", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.9.4", + "secp256k1-sys 0.11.0", ] [[package]] @@ -9149,6 +9178,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -9211,7 +9249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9830,7 +9868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -10844,10 +10882,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10869,7 +10907,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook 0.3.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11611,7 +11649,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11712,7 +11750,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12563,7 +12601,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index db089fc13fa..c9499201ae1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -149,6 +149,7 @@ strip-ansi-escapes = "0.2" tracing = "0.1" [dev-dependencies] +tauri = { version = "2", features = ["test"] } tauri-utils = "2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. @@ -156,3 +157,4 @@ tokio = { version = "1", features = ["test-util"] } # The relay's media validation, so the snapshot-sharing tests can prove the # full export → sanitize → relay-accept → import contract end to end. buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" } +buzz_pair_relay_pkg = { package = "buzz-pair-relay", path = "../../crates/buzz-pair-relay" } diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index aedd67854c1..b880790104f 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -9,7 +9,8 @@ use buzz_core_pkg::pairing::types::{AbortReason, PayloadType}; use futures_util::{SinkExt, StreamExt}; use nostr::ToBech32; use serde::Serialize; -use tauri::{AppHandle, Emitter, Manager, State}; +use tauri::{AppHandle, Emitter, Manager, Runtime, State}; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_util::sync::CancellationToken; @@ -18,6 +19,10 @@ use zeroize::Zeroizing; use crate::app_state::AppState; use crate::relay::{relay_api_base_url_with_override, relay_ws_url_with_override}; +/// Wall-clock cap for an active pairing WebSocket session. Starts when the +/// socket is accepted (immediately after split), not after NIP-42/EOSE setup. +pub const PAIRING_HARD_TIMEOUT: Duration = Duration::from_secs(130); + #[derive(Serialize, Clone)] struct PairingSasPayload { sas: String, @@ -270,23 +275,16 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str Ok(()) } -async fn pairing_ws_task( +async fn pairing_ws_task( relay_url: String, session: Arc>>, context: PairingTaskContext, cancel: CancellationToken, - mut outbound_rx: mpsc::Receiver, - app: AppHandle, + outbound_rx: mpsc::Receiver, + app: AppHandle, ) { - if let Err(e) = pairing_ws_task_inner( - &relay_url, - &session, - &context, - &cancel, - &mut outbound_rx, - &app, - ) - .await + if let Err(e) = + pairing_ws_task_inner(&relay_url, &session, &context, &cancel, outbound_rx, &app).await { if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); @@ -295,17 +293,33 @@ async fn pairing_ws_task( clear_pairing_session_if_current(&session, &context.generation, context.task_generation).await; } -async fn pairing_ws_task_inner( +async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, context: &PairingTaskContext, cancel: &CancellationToken, - outbound_rx: &mut mpsc::Receiver, - app: &AppHandle, + outbound_rx: mpsc::Receiver, + app: &AppHandle, ) -> Result<(), String> { let (ws, _) = connect_async(relay_url) .await .map_err(|e| format!("WebSocket connection failed: {e}"))?; + pairing_ws_task_on_socket(ws, relay_url, session, context, cancel, outbound_rx, app).await +} + +async fn pairing_ws_session_loop( + ws: tokio_tungstenite::WebSocketStream, + relay_url: &str, + session: &Arc>>, + context: &PairingTaskContext, + cancel: &CancellationToken, + mut outbound_rx: mpsc::Receiver, + app: &AppHandle, +) -> Result<(), String> +where + R: Runtime, + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ let (mut write, mut read) = ws.split(); handle_nip42_auth(&mut read, &mut write, session, relay_url).await?; @@ -325,9 +339,6 @@ async fn pairing_ws_task_inner( wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?; - let hard_timeout = tokio::time::sleep(Duration::from_secs(130)); - tokio::pin!(hard_timeout); - loop { if !pairing_task_is_current(&context.generation, context.task_generation) { break; @@ -335,14 +346,6 @@ async fn pairing_ws_task_inner( tokio::select! { _ = cancel.cancelled() => break, - _ = &mut hard_timeout => { - if pairing_task_is_current(&context.generation, context.task_generation) { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Session timed out".into(), - }); - } - break; - } Some(json_msg) = outbound_rx.recv() => { if let Err(e) = write.send(Message::Text(json_msg.into())).await { return Err(format!("publish failed: {e}")); @@ -459,8 +462,66 @@ async fn pairing_ws_task_inner( Ok(()) } -async fn import_recovered_identity( - app: &AppHandle, +async fn pairing_ws_task_on_socket( + ws: tokio_tungstenite::WebSocketStream, + relay_url: &str, + session: &Arc>>, + context: &PairingTaskContext, + cancel: &CancellationToken, + outbound_rx: mpsc::Receiver, + app: &AppHandle, +) -> Result<(), String> +where + R: Runtime, + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let relay_url = relay_url.to_string(); + let session = Arc::clone(session); + let context = context.clone(); + let cancel = cancel.clone(); + let worker_app = app.clone(); + let timeout_app = app.clone(); + let generation = Arc::clone(&context.generation); + let task_generation = context.task_generation; + + // Abort the worker on deadline so a stalled write.send cannot outlive the + // desktop hard timeout. timeout() alone only drops the future in-place and + // does not cancel an in-flight sink await on the same task. + let mut worker = tokio::spawn(async move { + pairing_ws_session_loop( + ws, + &relay_url, + &session, + &context, + &cancel, + outbound_rx, + &worker_app, + ) + .await + }); + + tokio::select! { + result = &mut worker => match result { + Ok(Ok(())) => Ok(()), + Ok(Err(message)) => Err(message), + Err(err) if err.is_cancelled() => Ok(()), + Err(err) => Err(format!("pairing task failed: {err}")), + }, + _ = tokio::time::sleep(PAIRING_HARD_TIMEOUT) => { + worker.abort(); + let _ = worker.await; + if pairing_task_is_current(&generation, task_generation) { + let _ = timeout_app.emit("pairing-error", PairingErrorPayload { + message: "Session timed out".into(), + }); + } + Ok(()) + } + } +} + +async fn import_recovered_identity( + app: &AppHandle, nsec: Zeroizing, generation: &Arc, generation_fence: &Arc>, @@ -531,11 +592,11 @@ fn recovery_result_after_completion( imported } -fn finish_recovery( +fn finish_recovery( imported: Result<(), String>, completion_result: Result<(), String>, context: &PairingTaskContext, - app: &AppHandle, + app: &AppHandle, ) -> Result<(), String> { if !pairing_task_is_current(&context.generation, context.task_generation) { return Ok(()); @@ -791,3 +852,7 @@ mod pairing_generation_tests; #[cfg(test)] #[path = "pairing_relay_tests.rs"] mod pairing_relay_tests; + +#[cfg(test)] +#[path = "pairing_timeout_contract_tests.rs"] +mod pairing_timeout_contract_tests; diff --git a/desktop/src-tauri/src/commands/pairing_timeout_contract_tests.rs b/desktop/src-tauri/src/commands/pairing_timeout_contract_tests.rs new file mode 100644 index 00000000000..c32c7f746aa --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_timeout_contract_tests.rs @@ -0,0 +1,208 @@ +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use buzz_core_pkg::pairing::session::PairingSession; +use buzz_pair_relay_pkg::CONN_TIMEOUT; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tauri::Listener; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_tungstenite::{tungstenite::Message, WebSocketStream}; +use tokio_util::sync::CancellationToken; + +use super::{pairing_ws_task_on_socket, PairingMode, PairingTaskContext, PAIRING_HARD_TIMEOUT}; + +#[test] +fn pair_relay_conn_timeout_outlives_desktop_pairing_hard_timeout() { + assert!( + CONN_TIMEOUT > PAIRING_HARD_TIMEOUT, + "CONN_TIMEOUT ({CONN_TIMEOUT:?}) must exceed desktop PAIRING_HARD_TIMEOUT ({PAIRING_HARD_TIMEOUT:?})" + ); +} + +/// Drives the production pairing task against a controlled WebSocket peer. +/// Setup consumes 11 seconds, so moving `hard_timeout` below NIP-42/EOSE would +/// make the desktop deadline 141 seconds and let the simulated 140-second +/// relay close win instead. +#[tokio::test(start_paused = true)] +async fn desktop_timeout_starts_when_websocket_connects() { + let relay_url = "ws://pairing.test".to_string(); + let (desktop_io, peer_io) = tokio::io::duplex(4096); + let desktop_ws = WebSocketStream::from_raw_socket(desktop_io, Role::Client, None).await; + let mut peer = WebSocketStream::from_raw_socket(peer_io, Role::Server, None).await; + + let app = tauri::test::mock_app(); + let app_handle = app.handle().clone(); + let (error_tx, mut error_rx) = mpsc::unbounded_channel(); + app_handle.listen("pairing-error", move |event| { + let _ = error_tx.send(event.payload().to_string()); + }); + + let (session, _) = PairingSession::new_source(relay_url.clone()); + let session = Arc::new(tokio::sync::Mutex::new(Some(session))); + let generation = Arc::new(AtomicU64::new(1)); + let context = PairingTaskContext { + mode: PairingMode::SendIdentity, + generation, + generation_fence: Arc::new(Mutex::new(())), + task_generation: 1, + }; + let (outbound_tx, outbound_rx) = mpsc::channel(1); + let desktop = tokio::spawn(async move { + let cancel = CancellationToken::new(); + pairing_ws_task_on_socket( + desktop_ws, + &relay_url, + &session, + &context, + &cancel, + outbound_rx, + &app_handle, + ) + .await + }); + + // With no NIP-42 challenge, Desktop spends its three-second challenge + // budget before proceeding to the subscription. + tokio::time::advance(Duration::from_secs(3)).await; + let req = peer.next().await.unwrap().unwrap(); + assert!( + matches!(req, Message::Text(ref text) if text.contains("\"REQ\"") && text.contains("\"pair\"")), + "expected desktop pairing subscription, got {req:?}" + ); + + tokio::time::advance(Duration::from_secs(8)).await; + peer.send(Message::Text(r#"["EOSE","pair"]"#.into())) + .await + .unwrap(); + outbound_tx.send("test-ready".into()).await.unwrap(); + for _ in 0..5 { + tokio::task::yield_now().await; + } + let ready = match peer.next().await { + Some(Ok(message)) => message, + other => panic!( + "desktop closed before entering post-EOSE loop: {other:?}; emitted={:?}", + error_rx.try_recv() + ), + }; + assert!( + matches!(ready, Message::Text(ref text) if text.as_str() == "test-ready"), + "expected post-EOSE desktop message, got {ready:?}" + ); + + // 129 seconds from connect: the desktop remains active and no expiry was + // emitted early. + tokio::time::advance(Duration::from_secs(118)).await; + tokio::task::yield_now().await; + assert!(!desktop.is_finished()); + assert!(error_rx.try_recv().is_err()); + + // Exactly 130 seconds from connect: the desktop must own expiry, ten + // seconds before the controlled peer's relay-style close. + tokio::time::advance(Duration::from_secs(1)).await; + for _ in 0..5 { + tokio::task::yield_now().await; + } + let expired_by_130 = desktop.is_finished(); + let timeout_payload = error_rx.try_recv().ok(); + + // Simulate the relay's independent orphan-socket deadline. If the desktop + // timer were started after the 11-second setup, this close would win at + // 140 seconds and surface "relay connection closed" instead. + tokio::time::advance(CONN_TIMEOUT - PAIRING_HARD_TIMEOUT).await; + let _ = peer.close(None).await; + for _ in 0..5 { + tokio::task::yield_now().await; + } + let desktop_result = desktop.await.unwrap(); + + assert!( + expired_by_130, + "pairing task did not expire 130 seconds after WebSocket establishment" + ); + assert_eq!(desktop_result, Ok(())); + let payload = match timeout_payload { + Some(payload) => payload, + None => error_rx + .try_recv() + .expect("pairing task did not emit an error before relay close"), + }; + let payload: Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(payload["message"], "Session timed out"); +} + +/// If the peer stops reading after the WebSocket handshake, a subscribe write +/// must not block past the desktop hard deadline. +#[tokio::test(start_paused = true)] +async fn stalled_subscribe_write_emits_session_timeout() { + let relay_url = "ws://pairing.test".to_string(); + let (desktop_io, peer_io) = tokio::io::duplex(1); + let desktop_ws = WebSocketStream::from_raw_socket(desktop_io, Role::Client, None).await; + let _peer = WebSocketStream::from_raw_socket(peer_io, Role::Server, None).await; + + let app = tauri::test::mock_app(); + let app_handle = app.handle().clone(); + let (error_tx, mut error_rx) = mpsc::unbounded_channel(); + app_handle.listen("pairing-error", move |event| { + let _ = error_tx.send(event.payload().to_string()); + }); + + let (session, _) = PairingSession::new_source(relay_url.clone()); + let session = Arc::new(tokio::sync::Mutex::new(Some(session))); + let generation = Arc::new(AtomicU64::new(1)); + let context = PairingTaskContext { + mode: PairingMode::SendIdentity, + generation, + generation_fence: Arc::new(Mutex::new(())), + task_generation: 1, + }; + let (_outbound_tx, outbound_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let desktop = tokio::spawn(async move { + pairing_ws_task_on_socket( + desktop_ws, + &relay_url, + &session, + &context, + &cancel, + outbound_rx, + &app_handle, + ) + .await + }); + + // Let the pairing deadline wrapper start before advancing virtual time. + for _ in 0..64 { + tokio::task::yield_now().await; + } + + for _ in 0..PAIRING_HARD_TIMEOUT.as_secs() { + tokio::time::advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + } + for _ in 0..64 { + if desktop.is_finished() { + break; + } + tokio::task::yield_now().await; + } + + if !desktop.is_finished() { + desktop.abort(); + panic!( + "pairing task did not finish within {PAIRING_HARD_TIMEOUT:?} after stalled subscribe write" + ); + } + let desktop_result = desktop.await.unwrap(); + assert_eq!(desktop_result, Ok(())); + let payload: Value = serde_json::from_str( + &error_rx + .try_recv() + .expect("expected Session timed out after stalled subscribe write"), + ) + .unwrap(); + assert_eq!(payload["message"], "Session timed out"); +}