diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index 36101550d..33e194496 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -901,9 +901,17 @@ async fn tokio_main() -> Result<()> { .as_secs(); let pubkey_hex = config.keys.public_key().to_hex(); - let mut relay = HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex) - .await - .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; + + // Parse SPROUT_AUTH_TAG into a nostr::Tag for NIP-OA relay membership delegation. + let relay_auth_tag: Option = std::env::var("SPROUT_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + .and_then(|s| sprout_sdk::nip_oa::parse_auth_tag(&s).ok()); + + let mut relay = + HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) + .await + .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; // Finding #22: tell the relay background task the watermark so it can use // `since = watermark - 5s` on the first REQ instead of `since=now`. diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index bfef400d9..b668d59f3 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -97,6 +97,8 @@ pub struct RestClient { pub http: reqwest::Client, pub base_url: String, pub keys: Keys, + /// Optional NIP-OA auth tag JSON for `x-auth-tag` header (relay membership delegation). + pub auth_tag_json: Option, } /// Whether an HTTP status code is retriable (transient server/rate-limit errors). @@ -231,18 +233,22 @@ impl RestClient { ) -> Result { let url = format!("{}{}", self.base_url, path); let body_owned = body_bytes.to_vec(); + let auth_tag_header = self.auth_tag_json.clone(); self.request_with_retry("POST", path, || { // NIP-98 is re-signed each attempt (fresh created_at). // sign_nip98 is infallible in practice (key is always valid). let auth = self .nip98_header("POST", &url, Some(&body_owned)) .unwrap_or_default(); - self.http + let mut req = self + .http .post(&url) .header("Authorization", auth) - .header("Content-Type", "application/json") - .body(body_owned.clone()) - .send() + .header("Content-Type", "application/json"); + if let Some(ref tag) = auth_tag_header { + req = req.header("x-auth-tag", tag); + } + req.body(body_owned.clone()).send() }) .await } @@ -412,6 +418,9 @@ pub struct HarnessRelay { /// Agent public key (hex) used as the `#p` filter on subscriptions. #[allow(dead_code)] agent_pubkey_hex: String, + /// Optional NIP-OA auth tag for relay membership delegation. + #[allow(dead_code)] + auth_tag: Option, /// Handle to the background task (for clean shutdown). /// Wrapped in `Option` so `shutdown()` can take ownership without conflicting /// with `Drop` (which only has `&mut self`). @@ -440,15 +449,19 @@ impl HarnessRelay { // ── Public API ──────────────────────────────────────────────────────────── /// Connect to relay and authenticate via NIP-42. + /// + /// `auth_tag` is an optional NIP-OA owner attestation included in the AUTH + /// event for relay membership delegation. pub async fn connect( relay_url: &str, keys: &Keys, agent_pubkey_hex: &str, + auth_tag: Option, ) -> Result { // Perform the initial connection and auth handshake. // Finding #8: capture the handshake buffer and pass it to the background // task so buffered messages aren't silently discarded. - let (ws, handshake_buffer) = do_connect(relay_url, keys).await?; + let (ws, handshake_buffer) = do_connect(relay_url, keys, auth_tag.as_ref()).await?; let (event_tx, event_rx) = mpsc::channel::>(event_channel_capacity()); let (observer_control_tx, observer_control_rx) = @@ -458,6 +471,7 @@ impl HarnessRelay { let bg_keys = keys.clone(); let bg_relay_url = relay_url.to_string(); let bg_agent_pubkey_hex = agent_pubkey_hex.to_string(); + let bg_auth_tag = auth_tag.clone(); let bg_handle = tokio::spawn(async move { run_background_task( @@ -469,6 +483,7 @@ impl HarnessRelay { bg_keys, bg_relay_url, bg_agent_pubkey_hex, + bg_auth_tag, ) .await; }); @@ -485,6 +500,7 @@ impl HarnessRelay { relay_url: relay_url.to_string(), keys: keys.clone(), agent_pubkey_hex: agent_pubkey_hex.to_string(), + auth_tag, bg_handle: Some(bg_handle), }) } @@ -609,6 +625,10 @@ impl HarnessRelay { http: self.http.clone(), base_url: relay_ws_to_http(&self.relay_url), keys: self.keys.clone(), + auth_tag_json: self + .auth_tag + .as_ref() + .and_then(|t| serde_json::to_string(t.as_slice()).ok()), } } @@ -1148,6 +1168,7 @@ async fn run_background_task( keys: Keys, relay_url: String, agent_pubkey_hex: String, + auth_tag: Option, ) { let mut state = BgState::new(); @@ -1162,6 +1183,7 @@ async fn run_background_task( &keys, &relay_url, &agent_pubkey_hex, + auth_tag.as_ref(), ) .await; if !handshake_ok { @@ -1178,6 +1200,7 @@ async fn run_background_task( &agent_pubkey_hex, &event_tx, &observer_control_tx, + auth_tag.as_ref(), ) .await { @@ -1202,6 +1225,7 @@ async fn run_background_task( &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ) .await, ReconnectOutcome::Shutdown @@ -1241,6 +1265,7 @@ async fn run_background_task( &agent_pubkey_hex, &event_tx, &observer_control_tx, + auth_tag.as_ref(), ) .await { @@ -1271,6 +1296,7 @@ async fn run_background_task( &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ) .await, ReconnectOutcome::Shutdown @@ -1307,6 +1333,7 @@ async fn run_background_task( &keys, &relay_url, &agent_pubkey_hex, + auth_tag.as_ref(), ) .await } @@ -1335,6 +1362,7 @@ async fn run_background_task( &agent_pubkey_hex, &event_tx, &observer_control_tx, + auth_tag.as_ref(), ) .await; match outcome { @@ -1355,6 +1383,7 @@ async fn run_background_task( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1375,6 +1404,7 @@ async fn run_background_task( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1409,6 +1439,7 @@ async fn run_background_task( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, + auth_tag.as_ref(), ).await { ReconnectOutcome::Shutdown => return, ReconnectOutcome::Ok => { @@ -1422,6 +1453,7 @@ async fn run_background_task( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1447,6 +1479,7 @@ async fn run_background_task( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, + auth_tag.as_ref(), ).await { ReconnectOutcome::Shutdown => return, ReconnectOutcome::Ok => { @@ -1460,6 +1493,7 @@ async fn run_background_task( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1478,6 +1512,7 @@ async fn run_background_task( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, + auth_tag.as_ref(), ).await { ReconnectOutcome::Shutdown => return, ReconnectOutcome::Ok => { @@ -1491,6 +1526,7 @@ async fn run_background_task( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1532,6 +1568,7 @@ async fn handle_ws_message( keys: &Keys, relay_url: &str, agent_pubkey_hex: &str, + auth_tag: Option<&nostr::Tag>, ) -> bool { match msg { Message::Text(text) => { @@ -1781,7 +1818,9 @@ async fn handle_ws_message( RelayMessage::Auth { challenge } => { // Finding #18: AUTH send failure must trigger reconnect. debug!("received mid-session AUTH challenge — re-authenticating"); - if let Err(e) = send_auth_response(ws, &challenge, relay_url, keys).await { + if let Err(e) = + send_auth_response(ws, &challenge, relay_url, keys, auth_tag).await + { warn!("failed to respond to mid-session AUTH challenge: {e} — triggering reconnect"); return false; } @@ -1833,6 +1872,7 @@ async fn process_handshake_buffer( keys: &Keys, relay_url: &str, agent_pubkey_hex: &str, + auth_tag: Option<&nostr::Tag>, ) -> bool { if buffer.is_empty() { return true; @@ -1875,6 +1915,7 @@ async fn process_handshake_buffer( keys, relay_url, agent_pubkey_hex, + auth_tag, ) .await; if !should_continue { @@ -2038,6 +2079,7 @@ async fn try_autonomous_reconnect( agent_pubkey_hex: &str, event_tx: &mpsc::Sender>, observer_control_tx: &mpsc::Sender, + auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { // Finding #42: 5 attempts, up to 16s base backoff. let backoffs = [ @@ -2054,7 +2096,7 @@ async fn try_autonomous_reconnect( attempt + 1, backoffs.len() ); - match do_connect(relay_url, keys).await { + match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; info!("autonomous reconnect succeeded (attempt {})", attempt + 1); @@ -2068,6 +2110,7 @@ async fn try_autonomous_reconnect( keys, relay_url, agent_pubkey_hex, + auth_tag, ) .await; if !handshake_ok { @@ -2140,6 +2183,7 @@ async fn wait_for_reconnect( event_tx: &mpsc::Sender>, observer_control_tx: &mpsc::Sender, skip_drain: bool, + auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { if !skip_drain { // Drain commands until we get Reconnect (or Shutdown). @@ -2167,7 +2211,7 @@ async fn wait_for_reconnect( let mut delay = Duration::from_secs(1); loop { info!("attempting relay reconnect to {relay_url}…"); - match do_connect(relay_url, keys).await { + match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; info!("relay reconnected to {relay_url}"); @@ -2181,6 +2225,7 @@ async fn wait_for_reconnect( keys, relay_url, agent_pubkey_hex, + auth_tag, ) .await; if !handshake_ok { @@ -2429,17 +2474,33 @@ fn extract_h_tag_uuid(event: &nostr::Event) -> Option { } /// Build and send a NIP-42 AUTH response event. +/// +/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the +/// AUTH event so the relay can use it for membership delegation fallback. async fn send_auth_response( ws: &mut WsStream, challenge: &str, relay_url: &str, keys: &Keys, + auth_tag: Option<&nostr::Tag>, ) -> Result<(), RelayError> { let relay_nostr_url: NostrUrl = relay_url .parse() .map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?; - let auth_event = EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?; + let auth_event = if let Some(tag) = auth_tag { + // Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags. + let tags = vec![ + nostr::Tag::parse(&["relay", relay_url]) + .map_err(|e| RelayError::Http(format!("tag parse error: {e}")))?, + nostr::Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayError::Http(format!("tag parse error: {e}")))?, + tag.clone(), + ]; + EventBuilder::new(nostr::Kind::Authentication, "", tags).sign_with_keys(keys)? + } else { + EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)? + }; let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; ws_send_timeout(ws, Message::Text(auth_msg.into()), WS_SEND_TIMEOUT_SECS).await?; @@ -2576,6 +2637,7 @@ pub(crate) fn parse_relay_message(text: &str) -> Result, ) -> Result<(WsStream, VecDeque), RelayError> { let parsed = relay_url .parse::() @@ -2594,7 +2656,7 @@ async fn do_connect( let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?; // ── Step 2: Build and send kind:22242 auth event ────────────────────── - send_auth_response(&mut ws, &challenge, relay_url, keys).await?; + send_auth_response(&mut ws, &challenge, relay_url, keys, auth_tag).await?; // ── Step 3: Wait for OK ─────────────────────────────────────────────── let event_id = { diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 5b44443be..bc7a08843 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -221,6 +221,7 @@ async fn do_connect( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> Result { let parsed = relay_url .parse::() @@ -236,7 +237,7 @@ async fn do_connect( // Wait for AUTH challenge (5s timeout). let challenge = wait_for_auth_challenge(&mut ws, Duration::from_secs(5)).await?; - let auth_event = build_auth_event(&challenge, relay_url, keys, api_token)?; + let auth_event = build_auth_event(&challenge, relay_url, keys, api_token, auth_tag)?; let event_id = auth_event.id.to_hex(); debug!("sending AUTH event {event_id}"); let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; @@ -318,18 +319,22 @@ async fn wait_for_ok( } /// Build a NIP-42 AUTH event for the given challenge. +/// +/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the +/// AUTH event so the relay can use it for membership delegation fallback. #[allow(clippy::result_large_err)] fn build_auth_event( challenge: &str, relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> Result { let relay_nostr_url: Url = relay_url .parse() .map_err(|e: url::ParseError| RelayClientError::Url(e.to_string()))?; if let Some(token) = api_token { - let tags = vec![ + let mut tags = vec![ Tag::parse(&["relay", relay_url]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, Tag::parse(&["challenge", challenge]) @@ -337,6 +342,19 @@ fn build_auth_event( Tag::parse(&["auth_token", token]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, ]; + if let Some(t) = auth_tag { + tags.push(t.clone()); + } + Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?) + } else if let Some(t) = auth_tag { + // Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags. + let tags = vec![ + Tag::parse(&["relay", relay_url]) + .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, + Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, + t.clone(), + ]; Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?) } else { Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?) @@ -353,9 +371,10 @@ async fn send_auth_response( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) { let result: Result<(), RelayClientError> = async { - let auth_event = build_auth_event(challenge, relay_url, keys, api_token)?; + let auth_event = build_auth_event(challenge, relay_url, keys, api_token, auth_tag)?; let msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; ws.send(Message::Text(msg.into())).await?; debug!("sent AUTH response for mid-session challenge"); @@ -377,6 +396,7 @@ async fn handle_ws_message( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> bool { match msg { Message::Text(text) => { @@ -429,7 +449,7 @@ async fn handle_ws_message( } RelayMessage::Auth { challenge } => { debug!("received mid-session AUTH challenge — re-authenticating"); - send_auth_response(ws, &challenge, relay_url, keys, api_token).await; + send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag).await; } } true @@ -463,13 +483,14 @@ async fn do_reconnect( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> bool { warn!("relay connection lost — reconnecting…"); state.cancel_pending(); let mut delay = Duration::from_secs(1); loop { - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, api_token, auth_tag).await { Ok(new_ws) => { tracing::info!("reconnected to relay at {relay_url}"); *ws = new_ws; @@ -544,6 +565,7 @@ async fn run_background_task( keys: Keys, relay_url: String, api_token: Option, + auth_tag: Option, ) { let mut state = BgState::new(); // Ticker for expiring timed-out pending operations (~1s granularity). @@ -557,14 +579,14 @@ async fn run_background_task( let needs_reconnect = match raw { Some(Ok(msg)) => { !handle_ws_message( - msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), + msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref(), ).await } Some(Err(e)) => { warn!("WebSocket error: {e}"); true } None => { debug!("WebSocket stream ended"); true } }; if needs_reconnect - && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await + && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; // Shutdown received during reconnect } @@ -581,7 +603,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(msg.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -611,7 +633,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(text.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -636,7 +658,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(msg.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -715,16 +737,17 @@ impl RelayClient { api_token: Option<&str>, auth_tag: Option, ) -> Result { - let ws = do_connect(relay_url, keys, api_token).await?; + let ws = do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?; let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let bg_keys = keys.clone(); let bg_relay_url = relay_url.to_string(); let bg_api_token = api_token.map(|t| t.to_string()); + let bg_auth_tag = auth_tag.clone(); let handle = tokio::spawn(async move { - run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token).await; + run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token, bg_auth_tag).await; }); Ok(Self { @@ -802,6 +825,15 @@ impl RelayClient { &self.keys } + /// Returns the NIP-OA auth tag JSON string for use in HTTP `x-auth-tag` headers. + /// + /// Returns `None` if no auth tag is configured (direct-member agents). + pub fn auth_tag_json(&self) -> Option { + self.auth_tag + .as_ref() + .and_then(|t| serde_json::to_string(t.as_slice()).ok()) + } + /// Returns the relay's server authority (host or host:port) for BUD-11 server tags. /// /// Uses the same logic as the desktop client's `extract_server_authority`: diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 46fff7cad..454d6eab5 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1017,6 +1017,7 @@ Default kind is 9 (stream message)." &self.client.relay_http_url(), self.client.server_domain().as_deref(), path, + self.client.auth_tag_json().as_deref(), ) .await { @@ -3113,6 +3114,7 @@ on send_message to upload and attach in one step." &self.client.relay_http_url(), self.client.server_domain().as_deref(), &p.file_path, + self.client.auth_tag_json().as_deref(), ) .await { diff --git a/crates/sprout-mcp/src/upload.rs b/crates/sprout-mcp/src/upload.rs index 17a964bee..9ac75c2c0 100644 --- a/crates/sprout-mcp/src/upload.rs +++ b/crates/sprout-mcp/src/upload.rs @@ -112,12 +112,16 @@ pub enum UploadError { /// /// Performs validation, SHA-256 hashing, Blossom auth signing, and the HTTP PUT. /// Returns the relay's [`BlobDescriptor`] on success. +/// +/// `auth_tag_json` is an optional NIP-OA auth tag (JSON-array string) sent as +/// the `x-auth-tag` header for relay membership delegation. pub async fn upload_file( http: &reqwest::Client, keys: &Keys, relay_http_url: &str, server_domain: Option<&str>, file_path: &str, + auth_tag_json: Option<&str>, ) -> Result { // 1. Validate path exists let metadata = std::fs::metadata(file_path).map_err(|e| { @@ -223,15 +227,16 @@ pub async fn upload_file( }; let url = format!("{}/media/upload", relay_http_url.trim_end_matches('/')); - let resp = http + let mut req = http .put(&url) .timeout(upload_timeout) .header("Authorization", &auth_header) .header("Content-Type", mime) - .header("X-SHA-256", &sha256) - .body(bytes) - .send() - .await?; + .header("X-SHA-256", &sha256); + if let Some(tag) = auth_tag_json { + req = req.header("x-auth-tag", tag); + } + let resp = req.body(bytes).send().await?; // 9. Handle response let status = resp.status(); diff --git a/crates/sprout-relay/src/api/bridge.rs b/crates/sprout-relay/src/api/bridge.rs index fd33f52ae..2c2a429f7 100644 --- a/crates/sprout-relay/src/api/bridge.rs +++ b/crates/sprout-relay/src/api/bridge.rs @@ -137,8 +137,9 @@ pub async fn submit_event( check_nip98_replay(&state, event_id_bytes)?; let pubkey_bytes = pubkey.serialize().to_vec(); - // Enforce relay membership - super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, None).await?; + // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; let event: nostr::Event = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; @@ -184,7 +185,8 @@ pub async fn query_events( check_nip98_replay(&state, event_id_bytes)?; let pubkey_bytes = pubkey.serialize().to_vec(); - super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, None).await?; + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; let filters: Vec = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; @@ -278,7 +280,8 @@ pub async fn count_events( check_nip98_replay(&state, event_id_bytes)?; let pubkey_bytes = pubkey.serialize().to_vec(); - super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, None).await?; + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; let filters: Vec = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; diff --git a/crates/sprout-relay/src/audio/handler.rs b/crates/sprout-relay/src/audio/handler.rs index 599b38920..63c889b49 100644 --- a/crates/sprout-relay/src/audio/handler.rs +++ b/crates/sprout-relay/src/audio/handler.rs @@ -117,6 +117,9 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc, channe } }; + // Extract NIP-OA auth tag before verify_auth_event consumes the event. + let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let relay_url = state.config.relay_url.clone(); let auth_ctx = match state .auth @@ -142,10 +145,14 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc, channe let pubkey_bytes = pubkey.serialize().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; - // ── Relay membership gate (NIP-43) ──────────────────────────────────────── - if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize(), None) - .await - .is_err() + // ── Relay membership gate (with NIP-OA fallback) ──────────────────────────── + if crate::api::relay_members::enforce_relay_membership( + &state, + &pubkey.serialize(), + auth_tag_json.as_deref(), + ) + .await + .is_err() { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied"); let _ = ws_send diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 5eba078f9..165cc5859 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -1,4 +1,10 @@ //! NIP-42 AUTH handler — verify challenge response, transition auth state. +//! +//! Relay membership enforcement uses the shared +//! [`crate::api::relay_members::enforce_relay_membership`] helper, which supports +//! NIP-OA owner-delegation fallback. For WebSocket auth, the NIP-OA `auth` tag +//! is extracted from the signed AUTH event itself (the tag is integrity-protected +//! by the event signature). use std::sync::Arc; @@ -8,49 +14,21 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -/// Check relay membership for a pubkey during NIP-42 auth. +/// Extract a NIP-OA `auth` tag from a verified AUTH event and serialize it as +/// the JSON-array string that [`sprout_sdk::nip_oa::verify_auth_tag`] expects. /// -/// Returns `true` if the pubkey is a relay member (or if membership enforcement -/// is disabled). Returns `false` and sends a rejection message if not a member. -async fn enforce_ws_relay_membership( - state: &AppState, - conn: &Arc, - conn_id: uuid::Uuid, - pubkey: &nostr::PublicKey, - event_id_hex: &str, -) -> bool { - if !state.config.require_relay_membership { - return true; +/// Returns `None` if no `auth` tag is present (direct-member auth path) or if +/// more than one `auth` tag exists (per NIP-OA spec: >1 auth tag ⇒ no valid tag). +pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { + let mut iter = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")); + let first = iter.next()?; + if iter.next().is_some() { + return None; // NIP-OA spec: treat >1 auth tag as no valid auth tag } - - let pubkey_hex = pubkey.to_hex(); - let is_member = match state.db.is_relay_member(&pubkey_hex).await { - Ok(v) => v, - Err(e) => { - warn!( - conn_id = %conn_id, - pubkey = %pubkey_hex, - error = %e, - "relay membership check failed, denying (fail-closed)" - ); - false - } - }; - - if !is_member { - warn!(conn_id = %conn_id, pubkey = %pubkey_hex, "not a relay member"); - metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member") - .increment(1); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - event_id_hex, - false, - "restricted: not a relay member", - )); - return false; - } - - true + serde_json::to_string(first.as_slice()).ok() } /// Handle a NIP-42 AUTH message: verify the challenge response and transition @@ -84,6 +62,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; + // Extract the NIP-OA auth tag before verification consumes the event. + // The tag is integrity-protected by the event's Schnorr signature — if + // 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 auth_svc = Arc::clone(&state.auth); @@ -123,8 +106,24 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } - // Relay membership gate — applies to all auth methods. - if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex).await { + // Relay membership gate — uses the shared helper with NIP-OA fallback. + if crate::api::relay_members::enforce_relay_membership( + &state, + &pubkey.serialize(), + auth_tag_json.as_deref(), + ) + .await + .is_err() + { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member"); + metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: not a relay member", + )); return; }