fix: unify NIP-OA relay membership enforcement across all ingress paths (#490)

This commit is contained in:
tlongwell-block
2026-05-06 11:01:39 -04:00
committed by GitHub
parent 6dbb66d858
commit 2357c3ddb3
8 changed files with 199 additions and 81 deletions
+11 -3
View File
@@ -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<nostr::Tag> = 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`.
+72 -10
View File
@@ -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<String>,
}
/// Whether an HTTP status code is retriable (transient server/rate-limit errors).
@@ -231,18 +233,22 @@ impl RestClient {
) -> Result<reqwest::Response, RelayError> {
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<nostr::Tag>,
/// 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<nostr::Tag>,
) -> Result<Self, RelayError> {
// 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::<Option<SproutEvent>>(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<nostr::Tag>,
) {
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<Option<SproutEvent>>,
observer_control_tx: &mpsc::Sender<Event>,
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<Option<SproutEvent>>,
observer_control_tx: &mpsc::Sender<Event>,
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<Uuid> {
}
/// 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<RelayMessage, RelayError
async fn do_connect(
relay_url: &str,
keys: &Keys,
auth_tag: Option<&nostr::Tag>,
) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
let parsed = relay_url
.parse::<url::Url>()
@@ -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 = {
+44 -12
View File
@@ -221,6 +221,7 @@ async fn do_connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
auth_tag: Option<&Tag>,
) -> Result<WsStream, RelayClientError> {
let parsed = relay_url
.parse::<url::Url>()
@@ -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<Event, RelayClientError> {
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<String>,
auth_tag: Option<Tag>,
) {
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<nostr::Tag>,
) -> Result<Self, RelayClientError> {
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<String> {
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`:
+2
View File
@@ -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
{
+10 -5
View File
@@ -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<BlobDescriptor, UploadError> {
// 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();
+7 -4
View File
@@ -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<nostr::Filter> = 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<nostr::Filter> = serde_json::from_slice(&body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
+11 -4
View File
@@ -117,6 +117,9 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, 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<AppState>, 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
+42 -43
View File
@@ -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<ConnectionState>,
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<String> {
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<ConnectionState>, 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<ConnectionState>, 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;
}