From 893069dfb9d7083d4efecfb6bf5c9e4530497b41 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 6 May 2026 09:38:42 -0400 Subject: [PATCH 1/2] fix: unify NIP-OA relay membership enforcement across all ingress paths When SPROUT_REQUIRE_RELAY_MEMBERSHIP=true, agents whose owner is a relay member should be allowed to connect via NIP-OA attestation. Previously, this fallback only worked for media and git transport endpoints. WebSocket NIP-42 auth had a separate enforce_ws_relay_membership() with no NIP-OA support, the HTTP bridge passed None for the auth tag, and the audio WebSocket handler also lacked the fallback. This patch removes the duplicate enforcement function and routes all five ingress paths through the single shared enforce_relay_membership() helper that already supports NIP-OA owner-delegation: - WebSocket NIP-42 (handlers/auth.rs): extract auth tag from the signed AUTH event, pass to shared helper - HTTP bridge /events, /query, /count (api/bridge.rs): read x-auth-tag header instead of passing None - Audio WebSocket (audio/handler.rs): extract auth tag from AUTH event, pass to shared helper - MCP client (relay_client.rs): include auth_tag in NIP-42 AUTH events (initial connect, reconnect, mid-session re-auth) - MCP media upload (upload.rs): send x-auth-tag header on PUT requests Additional spec conformance: - extract_auth_tag_json() rejects events with >1 auth tag per NIP-OA spec Security properties: - Fail-closed: DB errors, missing tags, invalid sigs all deny access - WS path: auth tag is integrity-protected by the event Schnorr signature - HTTP path: verify_auth_tag binds attestation to the NIP-98-authenticated agent pubkey (cannot replay another agent's tag) - No new attack surface: the only new accept path is valid NIP-OA sig + owner is relay member, which is the intended behavior --- crates/sprout-mcp/src/relay_client.rs | 56 ++++++++++++---- crates/sprout-mcp/src/server.rs | 2 + crates/sprout-mcp/src/upload.rs | 15 +++-- crates/sprout-relay/src/api/bridge.rs | 11 +-- crates/sprout-relay/src/audio/handler.rs | 15 +++-- crates/sprout-relay/src/handlers/auth.rs | 85 ++++++++++++------------ 6 files changed, 116 insertions(+), 68 deletions(-) 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; } From b18c61c6ebf3fda2d96c63de69505e1ce3ed795c Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Wed, 6 May 2026 10:07:41 -0400 Subject: [PATCH 2/2] fix: thread NIP-OA auth tag through ACP relay client and HTTP bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACP has its own WebSocket relay client (separate from sprout-mcp's RelayClient). It was not including the NIP-OA auth tag in NIP-42 AUTH events or sending x-auth-tag on HTTP bridge requests, so agents using SPROUT_AUTH_TAG could not connect to membership-enforced relays. Changes: - Parse SPROUT_AUTH_TAG into a nostr::Tag and pass to HarnessRelay::connect - Thread auth_tag through do_connect, send_auth_response, handle_ws_message, try_autonomous_reconnect, wait_for_reconnect, process_handshake_buffer - Add auth_tag_json field to RestClient, send x-auth-tag header on all HTTP bridge POST requests (/query, /events) E2E verified: agent with NIP-OA tag connects, discovers channels, receives tasks, and replies — all through a relay with SPROUT_REQUIRE_RELAY_MEMBERSHIP=true. --- crates/sprout-acp/src/main.rs | 14 ++++-- crates/sprout-acp/src/relay.rs | 82 +++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 13 deletions(-) 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 = {