Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/sprout-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ pub async fn submit_event(
let event: nostr::Event = serde_json::from_slice(&body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?;

// Mesh signaling kinds (24620 status report, 24621 connect request) are
// ephemeral and deliberately absent from ingest_event's per-kind allowlist.
// The desktop's Rust coordinator publishes them via this bridge, so route
// them to the mesh handlers — the HTTP twin of the WS door's special-casing
// in handlers::event. Membership was enforced above; the handlers re-check
// it fail-closed.
let kind_u32 = sprout_core::kind::event_kind_u32(&event);
if kind_u32 == sprout_core::kind::KIND_MESH_STATUS_REPORT
|| kind_u32 == sprout_core::kind::KIND_MESH_CONNECT_REQUEST
{
let event_id = event.id.to_hex();
return match crate::handlers::mesh_signaling::handle_mesh_event_http(
&state, &pubkey, &event,
)
.await
{
Ok(()) => Ok(Json(serde_json::json!({
"event_id": event_id,
"accepted": true,
"message": "",
}))),
Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)),
};
}

let auth = IngestAuth::Http {
pubkey,
scopes: sprout_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership
Expand Down
35 changes: 9 additions & 26 deletions crates/sprout-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,32 +497,15 @@ async fn handle_ephemeral_event(
// in the v1 direct-iroh mesh — validate membership + pair + fan out. It
// never carries iroh traffic and stores no endpoint state.
if event_kind_u32(&event) == KIND_MESH_CONNECT_REQUEST {
// Generous per-requester rate limit: each accepted 24621 makes the relay
// sign + fan TWO 24622s, so bound the amplification. 20/sec is far above
// any real interactive use; a buggy desktop loop can't storm the relay.
{
let key: [u8; 32] = auth_pubkey.to_bytes();
let now = std::time::Instant::now();
let mut entry = state
.mesh_connect_rate_limiter
.entry(key)
.or_insert((0, now));
let (count, window_start) = entry.value_mut();
if now.duration_since(*window_start).as_secs() >= 1 {
*count = 1;
*window_start = now;
} else {
*count += 1;
if *count > 20 {
drop(entry);
conn.send(RelayMessage::ok(
event_id_hex,
false,
"rate-limited: mesh connect request rate exceeded (20/sec)",
));
return;
}
}
// Per-requester rate limit shared with the HTTP door — see
// `mesh_signaling::connect_request_rate_limited` for rationale.
if super::mesh_signaling::connect_request_rate_limited(&state, &auth_pubkey) {
conn.send(RelayMessage::ok(
event_id_hex,
false,
"rate-limited: mesh connect request rate exceeded (20/sec)",
));
return;
}
let requester_hex = auth_pubkey.to_hex();
match super::mesh_signaling::handle_connect_request(&state, &requester_hex, &event).await {
Expand Down
177 changes: 176 additions & 1 deletion crates/sprout-relay/src/handlers/mesh_signaling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,80 @@ use std::sync::Arc;

use nostr::{EventBuilder, Kind, Tag};
use sprout_core::event::StoredEvent;
use sprout_core::kind::KIND_MESH_CALL_ME_NOW;
use sprout_core::kind::{
event_kind_u32, KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT,
};

use crate::api::relay_members::{check_relay_membership, MembershipDecision};
use crate::state::AppState;

/// Check + bump the per-requester 24621 rate limit (20/sec window).
///
/// Each accepted connect request makes the relay sign + fan TWO 24622s, so we
/// bound the amplification. 20/sec is far above any real interactive use; a
/// buggy desktop loop can't storm the relay. Shared by the WS door
/// (`handlers::event`) and the HTTP door (`handle_mesh_event_http`) — one
/// limiter, two transports.
pub(crate) fn connect_request_rate_limited(state: &AppState, pubkey: &nostr::PublicKey) -> bool {
let key: [u8; 32] = pubkey.to_bytes();
let now = std::time::Instant::now();
let mut entry = state
.mesh_connect_rate_limiter
.entry(key)
.or_insert((0, now));
let (count, window_start) = entry.value_mut();
if now.duration_since(*window_start).as_secs() >= 1 {
*count = 1;
*window_start = now;
false
} else {
*count += 1;
*count > 20
}
}

/// HTTP-door entry point for the two desktop-published mesh signaling kinds
/// (24620 status report, 24621 connect request).
///
/// These kinds are ephemeral, so `ingest_event`'s per-kind allowlist
/// (deliberately) rejects them — historically they arrived only over the WS
/// path in `handlers::event`. Since the desktop's Rust coordinator publishes
/// them via `POST /events` (NIP-98), the bridge routes them here instead.
/// Mirrors the WS door's checks in the same order: signature, pubkey-match
/// (strict — mesh kinds are never proxy-submittable), rate limit, then the
/// shared handlers. Membership is enforced both at the bridge and inside the
/// handlers (fail-closed `require_mesh_member`).
///
/// Returns the same message the WS door would put in its OK frame; the bridge
/// maps `Err` to HTTP 400.
pub async fn handle_mesh_event_http(
state: &Arc<AppState>,
auth_pubkey: &nostr::PublicKey,
event: &nostr::Event,
) -> Result<(), String> {
let event_clone = event.clone();
tokio::task::spawn_blocking(move || sprout_core::verification::verify_event(&event_clone))
.await
.map_err(|_| "error: internal verification error".to_string())?
.map_err(|e| format!("invalid: {e}"))?;

if event.pubkey != *auth_pubkey {
return Err("invalid: event pubkey does not match authenticated identity".to_string());
}

let pubkey_hex = auth_pubkey.to_hex();
match event_kind_u32(event) {
k if k == KIND_MESH_STATUS_REPORT => handle_status_report(state, &pubkey_hex, event).await,
k if k == KIND_MESH_CONNECT_REQUEST => {
if connect_request_rate_limited(state, auth_pubkey) {
return Err("rate-limited: mesh connect request rate exceeded (20/sec)".to_string());
}
handle_connect_request(state, &pubkey_hex, event).await
}
k => Err(format!("invalid: kind {k} is not a mesh signaling kind")),
}
}

/// Parsed `KIND_MESH_CONNECT_REQUEST` (24621) content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectRequest {
Expand Down Expand Up @@ -609,4 +678,110 @@ mod tests {
);
assert!(target_rx.try_recv().is_err(), "target receives no event");
}

// ── HTTP door (handle_mesh_event_http) ──────────────────────────────────
// Regression coverage for the post-#879 transport: the desktop's Rust
// coordinator publishes 24620/24621 via POST /events, which used to fall
// into ingest_event's allowlist and 400 with "unknown event kind".

fn signed_connect_request(keys: &nostr::Keys, target_hex: &str) -> nostr::Event {
let content = serde_json::json!({
"self_endpoint_addr": "SELF_ADDR",
"peer_endpoint_addr": "PEER_ADDR",
"attempt_id": "attempt-http-1"
})
.to_string();
nostr::EventBuilder::new(nostr::Kind::Custom(24621), content)
.tags([nostr::Tag::parse(["p", target_hex]).unwrap()])
.sign_with_keys(keys)
.unwrap()
}

#[tokio::test]
async fn http_door_accepts_connect_request_and_emits_pair() {
let state = test_state().await;
let requester = nostr::Keys::generate();
let requester_hex = requester.public_key().to_hex();
let target_hex = nostr::Keys::generate().public_key().to_hex();
let (_rc, mut requester_rx) =
register_call_me_now_sub(&state, &requester_hex, "http_requester");
let (_tc, mut target_rx) = register_call_me_now_sub(&state, &target_hex, "http_target");

handle_mesh_event_http(
&state,
&requester.public_key(),
&signed_connect_request(&requester, &target_hex),
)
.await
.expect("HTTP door accepts a valid connect request");

event_from_ws_message(requester_rx.try_recv().expect("requester call-me-now"));
event_from_ws_message(target_rx.try_recv().expect("target call-me-now"));
}

#[tokio::test]
async fn http_door_rejects_pubkey_mismatch() {
let state = test_state().await;
let signer = nostr::Keys::generate();
let other = nostr::Keys::generate();
let target_hex = nostr::Keys::generate().public_key().to_hex();

let err = handle_mesh_event_http(
&state,
&other.public_key(),
&signed_connect_request(&signer, &target_hex),
)
.await
.expect_err("signer != authenticated identity must be rejected");
assert!(err.contains("does not match"), "unexpected error: {err}");
}

#[tokio::test]
async fn http_door_rejects_bad_signature() {
let state = test_state().await;
let keys = nostr::Keys::generate();
let target_hex = nostr::Keys::generate().public_key().to_hex();
let mut event = signed_connect_request(&keys, &target_hex);
// Tamper after signing.
event.content = "{}".to_string();

let err = handle_mesh_event_http(&state, &keys.public_key(), &event)
.await
.expect_err("tampered event must be rejected");
assert!(err.starts_with("invalid:"), "unexpected error: {err}");
}

#[tokio::test]
async fn http_door_rejects_non_mesh_kind() {
let state = test_state().await;
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(20001), "online")
.sign_with_keys(&keys)
.unwrap();

let err = handle_mesh_event_http(&state, &keys.public_key(), &event)
.await
.expect_err("only 24620/24621 route through the mesh HTTP door");
assert!(
err.contains("not a mesh signaling kind"),
"unexpected error: {err}"
);
}

#[tokio::test]
async fn connect_request_rate_limiter_trips_on_21st_in_window() {
let state = test_state().await;
let pubkey = nostr::Keys::generate().public_key();
for i in 0..20 {
assert!(
!connect_request_rate_limited(&state, &pubkey),
"request {} should pass",
i + 1
);
}
assert!(
connect_request_rate_limited(&state, &pubkey),
"21st request in the same window must be limited"
);
}
}