diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 2484fc7d14c..538fa0098fe 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -1,6 +1,6 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use reqwest::Method; +use reqwest::{Method, RequestBuilder}; use serde::de::DeserializeOwned; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -409,6 +409,37 @@ pub async fn query_relay_at_with_keys( .await } +/// Build an authenticated relay HTTP request using already-signed NIP-98 auth. +/// +/// The caller owns request ordering around this helper: rate-limit admission, +/// egress checks, URL/body construction, send, error classification, and response +/// parsing remain outside. `body` is accepted as final bytes so this helper never +/// reserializes, normalizes, or changes the payload that was signed. +fn build_authenticated_relay_request( + client: &reqwest::Client, + method: Method, + url: &str, + auth: &str, + body: Option>, + auth_tag: Option<&str>, + timeout: Option, +) -> RequestBuilder { + let mut request = client.request(method, url).header("Authorization", auth); + if body.is_some() { + request = request.header("Content-Type", "application/json"); + } + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + if let Some(body) = body { + request = request.body(body); + } + request +} + /// Issue an authenticated `POST /query` and parse the response, applying the /// per-request `timeout` that bounds a stalled or half-open relay connection. /// @@ -425,19 +456,18 @@ async fn send_query_request( body_bytes: Vec, timeout: std::time::Duration, ) -> Result, String> { - let mut request = http_client - .post(url) - .header("Authorization", auth) - .header("Content-Type", "application/json") - .timeout(timeout); - if let Some(tag) = auth_tag { - request = request.header("x-auth-tag", tag); - } - let response = request - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let response = build_authenticated_relay_request( + http_client, + Method::POST, + url, + auth, + Some(body_bytes), + auth_tag, + Some(timeout), + ) + .send() + .await + .map_err(|e| classify_request_error(&e))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } diff --git a/desktop/src-tauri/src/relay/get.rs b/desktop/src-tauri/src/relay/get.rs index 7d0855f463f..95226d3bfcd 100644 --- a/desktop/src-tauri/src/relay/get.rs +++ b/desktop/src-tauri/src/relay/get.rs @@ -4,8 +4,8 @@ use serde::de::DeserializeOwned; use crate::app_state::AppState; use super::{ - build_nip98_auth_header, classify_request_error, parse_json_response, - relay_api_base_url_with_override, relay_error_message, + build_authenticated_relay_request, build_nip98_auth_header, classify_request_error, + parse_json_response, relay_api_base_url_with_override, relay_error_message, }; /// Execute an authenticated GET against the active relay and decode its JSON body. @@ -23,13 +23,18 @@ pub async fn get_relay_json( path_with_query ); let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; - let response = state - .http_client - .get(&url) - .header("Authorization", auth) - .send() - .await - .map_err(|error| classify_request_error(&error))?; + let response = build_authenticated_relay_request( + &state.http_client, + Method::GET, + &url, + &auth, + None, + None, + None, + ) + .send() + .await + .map_err(|error| classify_request_error(&error))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index b6a5703fd96..a6b79a6cd2d 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -28,15 +28,18 @@ pub async fn submit_signed_event_at_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let response = build_authenticated_relay_request( + &state.http_client, + Method::POST, + &url, + &auth_header, + Some(body_bytes), + None, + None, + ) + .send() + .await + .map_err(|e| classify_request_error(&e))?; if !response.status().is_success() { return Err(relay_error_message(response).await); diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index f2928cbf612..251845e4310 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -2,12 +2,162 @@ //! Extracted from `relay.rs` to keep that module under the file-size ratchet. use super::{ - build_profile_event, classify_intercepted_response, effective_agent_relay_url, - extract_retry_in_hint, parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE, + build_authenticated_relay_request, build_profile_event, classify_intercepted_response, + effective_agent_relay_url, extract_retry_in_hint, parse_command_response, relay_http_base_url, + MALFORMED_RESPONSE_MESSAGE, }; use serde::Deserialize; -// ── extract_retry_in_hint ──────────────────────────────────────────────── +const AUTH_HEADER: reqwest::header::HeaderName = reqwest::header::AUTHORIZATION; +const CONTENT_TYPE_HEADER: reqwest::header::HeaderName = reqwest::header::CONTENT_TYPE; +const X_AUTH_TAG_HEADER: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-auth-tag"); + +fn assert_header_values( + request: &reqwest::Request, + expected: &[(&reqwest::header::HeaderName, &[&str])], +) { + let headers = request.headers(); + let actual_names: std::collections::BTreeSet = headers + .keys() + .map(|name| name.as_str().to_ascii_lowercase()) + .collect(); + let expected_names: std::collections::BTreeSet = expected + .iter() + .map(|(name, _)| name.as_str().to_ascii_lowercase()) + .collect(); + assert_eq!(actual_names, expected_names, "unexpected header set"); + + for (name, values) in expected { + let actual: Vec<&str> = headers + .get_all(*name) + .iter() + .map(|value| value.to_str().expect("test header must be visible text")) + .collect(); + assert_eq!(actual, *values, "unexpected values for {name}"); + } +} + +fn request_body_bytes(request: &reqwest::Request) -> Option<&[u8]> { + request.body().and_then(reqwest::Body::as_bytes) +} + +#[test] +fn authenticated_get_request_matches_existing_shape() { + let client = reqwest::Client::new(); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::GET, + "https://relay.example/info?verbose=1", + "Nostr signed-get", + None, + None, + None, + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::GET); + assert_eq!( + request.url().as_str(), + "https://relay.example/info?verbose=1" + ); + assert_header_values(&request, &[(&AUTH_HEADER, &["Nostr signed-get"])]); + assert!(request.timeout().is_none()); + assert!(request_body_bytes(&request).is_none()); +} + +#[test] +fn authenticated_json_post_request_preserves_final_body_bytes() { + let client = reqwest::Client::new(); + let body = br#"[{"kinds":[9],"limit":1}]"#.to_vec(); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::POST, + "https://relay.example/query", + "Nostr signed-post", + Some(body.clone()), + None, + None, + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.url().as_str(), "https://relay.example/query"); + assert_header_values( + &request, + &[ + (&AUTH_HEADER, &["Nostr signed-post"]), + (&CONTENT_TYPE_HEADER, &["application/json"]), + ], + ); + assert!( + request.headers().get("x-auth-tag").is_none(), + "absence of x-auth-tag must remain absence, not an empty header" + ); + assert!(request.timeout().is_none()); + assert_eq!(request_body_bytes(&request), Some(body.as_slice())); +} + +#[test] +fn authenticated_json_post_request_keeps_auth_tag_when_present() { + let client = reqwest::Client::new(); + let body = b"{}".to_vec(); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::POST, + "https://relay.example/event-submit", + "Nostr signed-event", + Some(body.clone()), + Some("owner-auth-tag"), + None, + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.url().as_str(), "https://relay.example/event-submit"); + assert_header_values( + &request, + &[ + (&AUTH_HEADER, &["Nostr signed-event"]), + (&CONTENT_TYPE_HEADER, &["application/json"]), + (&X_AUTH_TAG_HEADER, &["owner-auth-tag"]), + ], + ); + assert!(request.timeout().is_none()); + assert_eq!(request_body_bytes(&request), Some(body.as_slice())); +} + +#[test] +fn authenticated_query_request_preserves_timeout() { + let client = reqwest::Client::new(); + let timeout = std::time::Duration::from_millis(250); + let request = build_authenticated_relay_request( + &client, + reqwest::Method::POST, + "https://relay.example/query", + "Nostr signed-query", + Some(b"[]".to_vec()), + None, + Some(timeout), + ) + .build() + .expect("request should build"); + + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!(request.url().as_str(), "https://relay.example/query"); + assert_header_values( + &request, + &[ + (&AUTH_HEADER, &["Nostr signed-query"]), + (&CONTENT_TYPE_HEADER, &["application/json"]), + ], + ); + assert_eq!(request.timeout(), Some(&timeout)); + assert_eq!(request_body_bytes(&request), Some(&b"[]"[..])); +} #[test] fn extracts_hint_from_429_body() {