mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(relay): accept mesh signaling kinds (24620/24621) via POST /events (#946)
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user