From 539eefd35a274db7a1dbef1c338c842824860f35 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Mon, 17 Aug 2026 21:08:54 -0300 Subject: [PATCH 1/3] fix: verify event signature before spam gate to prevent id-based censorship --- docs/TRANSPORT_V2_SPEC.md | 12 +++ src/app.rs | 221 ++++++++++++++++++++++++++++++++------ src/spam_gate.rs | 12 +++ 3 files changed, 210 insertions(+), 35 deletions(-) diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md index cc476864..2aa8987a 100644 --- a/docs/TRANSPORT_V2_SPEC.md +++ b/docs/TRANSPORT_V2_SPEC.md @@ -235,6 +235,18 @@ transport, whose outer key is a throwaway with no pre-validatable signal. re-sent identical event id before decryption. The existing 10-second freshness window (post-decrypt, on the inner `created_at`) still applies as the precise stale-event check. +- **Validation order is load-bearing.** `accept_event` runs PoW → kind → + **event signature** → gate (replay dedup, then the lane check) → + `unwrap_incoming` (decrypt). The signature check must stay ahead of the + gate: a nostr event id commits to `[0, pubkey, created_at, kind, tags, + content]` and *not* to `sig`, so a copy of a victim's event with only `sig` + tampered keeps the victim's id and its mined PoW. Recording ids before + authentication would make the dedup a censorship primitive — the forged copy + gets recorded, the genuine event that follows is dropped as a replay, and + per the "no reply for dropped events" rule the sender never learns why. + Verifying first costs one Schnorr check on events the gate would have + dropped, and still runs entirely before the NIP-44 decrypt the gate exists + to protect. - **Discoverability** (`src/nip33.rs`): the kind-38385 info event carries a `pow_first_contact` tag next to `pow`. A dropped event gets no `cant-do` reply — it never reaches a handler — so the info event is the only way a diff --git a/src/app.rs b/src/app.rs index fd9a9b38..abfe9234 100644 --- a/src/app.rs +++ b/src/app.rs @@ -51,6 +51,7 @@ use crate::app::trade_pubkey::trade_pubkey_action; use crate::db::add_new_user; use crate::db::is_user_present; use crate::lightning::LndConnector; +use crate::spam_gate::SpamGate; use crate::util::enqueue_cant_do_msg; use crate::Result; @@ -295,8 +296,15 @@ async fn handle_message_action( /// Decode and fully validate one relay event into a dispatchable /// `(action, message, unwrapped)` triple, or `None` if it must be skipped -/// (failed PoW, wrong kind, spam-gate drop, decrypt failure, stale, missing -/// inner signature, failed trade-index, failed inner verify, no action). +/// (failed PoW, wrong kind, invalid event signature, spam-gate drop, decrypt +/// failure, stale, missing inner signature, failed trade-index, failed inner +/// verify, no action). +/// +/// **Validation order is load-bearing**: the event signature is checked before +/// the spam gate, so the gate only ever records ids that survived +/// authentication. Recording an unauthenticated id would let anyone who can +/// deliver an event to the daemon censor a trade message by injecting a +/// same-id, signature-tampered copy first — the id does not commit to `sig`. /// /// This is the transport + validation **prologue** shared VERBATIM by `run` /// (Lightning) and `run_cashu` (Cashu) so the two event loops cannot drift @@ -310,7 +318,7 @@ async fn accept_event( pow: u8, pow_first_contact: u8, accepted_kind: Kind, - is_v2: bool, + gate: Option<&SpamGate>, ) -> Option<(Action, Message, UnwrappedMessage)> { // Verify proof of work if !event.check_pow(pow) { @@ -321,41 +329,49 @@ async fn accept_event( if event.kind != accepted_kind { return None; } + // Authenticate the event BEFORE anything downstream records state keyed on + // it. A nostr event id commits to `[0, pubkey, created_at, kind, tags, + // content]` — **not** to `sig` — so a copy with a tampered signature keeps + // the victim's id. Verifying here (cheap Schnorr, pre-decrypt) is what + // makes the spam gate's dedup safe: only ids that are provably the + // author's own are ever recorded, so a forged copy cannot get the genuine + // event dropped as a replay. `unwrap_incoming` re-checks the signature for + // both transports; this is the daemon's own gate, not a substitute. + if event.verify().is_err() { + tracing::warn!("Dropping event {} with an invalid signature", event.id); + return None; + } // Phase 2 anti-spam gate (protocol v2 / kind 14 only): // cheap pre-validation BEFORE paying the NIP-44 decrypt - // cost. v1 gift wraps skip this — their outer key is a - // throwaway with no pre-validatable signal. - if is_v2 { - if let Some(gate) = crate::spam_gate::SpamGate::global() { - let now = chrono::Utc::now().timestamp(); - // Dedup: drop a re-sent identical event (defense in - // depth against replay floods). - if gate.is_replay(event.id, now) { - tracing::debug!("Dropping replayed event {}", event.id); - return None; - } - // Two lanes: a sender already in an active trade is - // fast-pathed (only the base `pow` already checked - // above applies); an unseen first-contact sender - // must clear the stiffer `pow_first_contact` before - // we decrypt. New orders/takes legitimately arrive - // here — so does spam, hence the PoW toll. - if !gate.is_known(&event.pubkey.to_string()) && !event.check_pow(pow_first_contact) { - tracing::info!( - "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", - event.pubkey, - pow_first_contact - ); - return None; - } + // cost. `None` means the gate does not apply: v1 gift wraps + // (throwaway outer key, no pre-validatable signal) or no + // gate installed (fail-open). + if let Some(gate) = gate { + let now = chrono::Utc::now().timestamp(); + // Dedup: drop a re-sent identical event (defense in + // depth against replay floods). Safe here and not + // earlier: the id is only recorded once the signature + // above proved the event is the author's own. + if gate.is_replay(event.id, now) { + tracing::debug!("Dropping replayed event {}", event.id); + return None; + } + // Two lanes: a sender already in an active trade is + // fast-pathed (only the base `pow` already checked + // above applies); an unseen first-contact sender + // must clear the stiffer `pow_first_contact` before + // we decrypt. New orders/takes legitimately arrive + // here — so does spam, hence the PoW toll. + if !gate.is_known(&event.pubkey.to_string()) && !event.check_pow(pow_first_contact) { + tracing::info!( + "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", + event.pubkey, + pow_first_contact + ); + return None; } } - // Validate event signature - if event.verify().is_err() { - tracing::warn!("Error in event verification") - }; - // Mostro-core dispatches on the event kind: the gift wrap // path handles the dual-key layout (identity key signs // seal, trade key authors rumor), the kind-14 path the @@ -463,6 +479,8 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { while let Some(notification) = notifications.next().await { if let ClientNotification::Event { event, .. } = notification { + // The gate is v2-only; `None` fail-opens (v1, or not installed). + let gate = if is_v2 { SpamGate::global() } else { None }; let Some((action, message, unwrapped)) = accept_event( &ctx, &event, @@ -470,7 +488,7 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { pow, pow_first_contact, accepted_kind, - is_v2, + gate, ) .await else { @@ -514,6 +532,8 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> { while let Some(notification) = notifications.next().await { if let ClientNotification::Event { event, .. } = notification { + // The gate is v2-only; `None` fail-opens (v1, or not installed). + let gate = if is_v2 { SpamGate::global() } else { None }; let Some((action, message, unwrapped)) = accept_event( &ctx, &event, @@ -521,7 +541,7 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> { pow, pow_first_contact, accepted_kind, - is_v2, + gate, ) .await else { @@ -677,6 +697,137 @@ mod tests { // No-op: ensure no panic } + /// Ordering contract of [`accept_event`]: the event signature is verified + /// **before** the spam gate records the id. A nostr event id does not + /// commit to `sig`, so without that order anyone able to deliver an event + /// to the daemon could censor a trade message by racing in a same-id, + /// signature-tampered copy: the copy would be recorded and the genuine + /// event dropped as a replay. + mod accept_event_ordering_tests { + use super::*; + use crate::app::context::test_utils::{test_settings, TestContextBuilder}; + use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; + use mostro_core::nip59::WrapOptions; + use mostro_core::transport::wrap_message_nip44; + use sqlx::SqlitePool; + use std::sync::Arc; + + async fn create_migrated_ctx() -> AppContext { + let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); + sqlx::migrate!("./migrations") + .run(pool.as_ref()) + .await + .unwrap(); + TestContextBuilder::new() + .with_pool(pool) + .with_settings(test_settings()) + .build() + } + + /// A protocol-v2 (kind 14) event addressed to `mostro`, in full-privacy + /// mode (trade key doubles as identity, so no identity proof is needed). + fn v2_event(mostro: &Keys) -> Event { + let trade = create_test_keys(); + let message = create_test_message(Action::FiatSent, None); + wrap_message_nip44( + &message, + &trade, + &trade, + mostro.public_key(), + WrapOptions::default(), + ) + .expect("wrap kind-14 event") + } + + /// The attacker's copy: only `sig` is replaced, which leaves the id — + /// and therefore the mined PoW — untouched. + fn with_tampered_signature(event: &Event) -> Event { + let decoy = EventBuilder::new(NostrKind::TextNote, "decoy") + .finalize(&create_test_keys()) + .expect("sign decoy"); + let mut forged = event.clone(); + forged.sig = decoy.sig; + forged + } + + async fn accept( + ctx: &AppContext, + event: &Event, + mostro: &Keys, + gate: &SpamGate, + ) -> Option<(Action, Message, UnwrappedMessage)> { + accept_event( + ctx, + event, + mostro, + 0, + 0, + NostrKind::PrivateDirectMessage, + Some(gate), + ) + .await + } + + #[tokio::test] + async fn tampered_copy_does_not_censor_the_genuine_event() { + let ctx = create_migrated_ctx().await; + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + let mostro = create_test_keys(); + let genuine = v2_event(&mostro); + let forged = with_tampered_signature(&genuine); + + assert_eq!(forged.id, genuine.id, "tampering sig must preserve the id"); + assert!(forged.verify().is_err(), "the copy must not verify"); + + assert!( + accept(&ctx, &forged, &mostro, &gate).await.is_none(), + "an event with an invalid signature must be dropped" + ); + assert!( + accept(&ctx, &genuine, &mostro, &gate).await.is_some(), + "the genuine event must survive a forged same-id copy" + ); + } + + #[tokio::test] + async fn genuine_duplicate_is_still_dropped_as_a_replay() { + let ctx = create_migrated_ctx().await; + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + let mostro = create_test_keys(); + let genuine = v2_event(&mostro); + + assert!( + accept(&ctx, &genuine, &mostro, &gate).await.is_some(), + "first sighting is accepted" + ); + assert!( + accept(&ctx, &genuine, &mostro, &gate).await.is_none(), + "the replay guard still drops a re-sent identical event" + ); + } + + #[tokio::test] + async fn invalid_signature_is_dropped_without_a_gate() { + // v1 gift wraps and un-installed gates take the `None` path; the + // signature check must reject there too. + let ctx = create_migrated_ctx().await; + let mostro = create_test_keys(); + let forged = with_tampered_signature(&v2_event(&mostro)); + + let accepted = accept_event( + &ctx, + &forged, + &mostro, + 0, + 0, + NostrKind::PrivateDirectMessage, + None, + ) + .await; + assert!(accepted.is_none()); + } + } + mod check_trade_index_tests { use super::*; use crate::app::context::test_utils::{test_settings, TestContextBuilder}; diff --git a/src/spam_gate.rs b/src/spam_gate.rs index d56adb25..37748cc8 100644 --- a/src/spam_gate.rs +++ b/src/spam_gate.rs @@ -68,6 +68,9 @@ impl ReplayGuard { /// present within the window (i.e. a replay the caller should drop). /// Expired entries are pruned on the way through so the map stays bounded /// by the in-window event rate. + /// + /// Caller contract: `id` must belong to an event whose signature already + /// verified — see [`SpamGate::is_replay`]. fn check_and_record(&mut self, id: EventId, now: i64) -> bool { let cutoff = now - self.window_secs; self.seen.retain(|_, &mut seen_at| seen_at >= cutoff); @@ -138,6 +141,15 @@ impl SpamGate { /// Record `id` and report whether it is a replay to drop. A poisoned lock /// degrades to `false` (never drop a real message because dedup state was /// lost). + /// + /// **Only call this for events whose signature has already been verified.** + /// A nostr event id commits to `[0, pubkey, created_at, kind, tags, + /// content]` and *not* to `sig`, so a signature-tampered copy of a victim's + /// event carries the victim's id. Recording ids before authentication would + /// turn this dedup into a censorship primitive: the forged copy gets + /// recorded, and the genuine event that follows is dropped here as a + /// replay. `accept_event` verifies the event signature before reaching this + /// call for exactly that reason. pub fn is_replay(&self, id: EventId, now: i64) -> bool { match self.replay.lock() { Ok(mut guard) => guard.check_and_record(id, now), From 0e2b3d976b567b2efdccb2b2cf078eafc720caad Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Tue, 18 Aug 2026 02:10:38 -0300 Subject: [PATCH 2/3] docs: clarify spam gate lane decision timing and consolidate test helpers --- src/app.rs | 73 ++++++++++++++++++++---------------------------------- 1 file changed, 27 insertions(+), 46 deletions(-) diff --git a/src/app.rs b/src/app.rs index abfe9234..ec557688 100644 --- a/src/app.rs +++ b/src/app.rs @@ -362,6 +362,13 @@ async fn accept_event( // must clear the stiffer `pow_first_contact` before // we decrypt. New orders/takes legitimately arrive // here — so does spam, hence the PoW toll. + // + // The lane is decided on a *verified* author on + // purpose: `is_known` keys off `event.pubkey`, and in + // v2 the trade keys of active orders are public by + // design. Deciding it before the signature check would + // let any flooder claim a known key and skip the + // first-contact toll entirely. if !gate.is_known(&event.pubkey.to_string()) && !event.check_pow(pow_first_contact) { tracing::info!( "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", @@ -616,6 +623,20 @@ mod tests { ) } + // An AppContext backed by a fresh in-memory database with the migrations + // applied. Shared by every child test module through `use super::*`. + async fn create_migrated_ctx() -> AppContext { + let pool = std::sync::Arc::new(sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap()); + sqlx::migrate!("./migrations") + .run(pool.as_ref()) + .await + .unwrap(); + crate::app::context::test_utils::TestContextBuilder::new() + .with_pool(pool) + .with_settings(crate::app::context::test_utils::test_settings()) + .build() + } + // Helper function to create an UnwrappedMessage for testing. Identity and // sender (trade key) are distinct to mirror the canonical Mostro flow. fn create_test_unwrapped_message() -> UnwrappedMessage { @@ -705,24 +726,9 @@ mod tests { /// event dropped as a replay. mod accept_event_ordering_tests { use super::*; - use crate::app::context::test_utils::{test_settings, TestContextBuilder}; use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; use mostro_core::nip59::WrapOptions; use mostro_core::transport::wrap_message_nip44; - use sqlx::SqlitePool; - use std::sync::Arc; - - async fn create_migrated_ctx() -> AppContext { - let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); - sqlx::migrate!("./migrations") - .run(pool.as_ref()) - .await - .unwrap(); - TestContextBuilder::new() - .with_pool(pool) - .with_settings(test_settings()) - .build() - } /// A protocol-v2 (kind 14) event addressed to `mostro`, in full-privacy /// mode (trade key doubles as identity, so no identity proof is needed). @@ -756,13 +762,15 @@ mod tests { mostro: &Keys, gate: &SpamGate, ) -> Option<(Action, Message, UnwrappedMessage)> { + // Same constant the event loops derive `is_v2` from, so the test + // fails if it ever drifts from `Transport::Nip44Direct`'s kind. accept_event( ctx, event, mostro, 0, 0, - NostrKind::PrivateDirectMessage, + NostrKind::from(crate::config::constants::DM_EVENT_KIND), Some(gate), ) .await @@ -820,7 +828,7 @@ mod tests { &mostro, 0, 0, - NostrKind::PrivateDirectMessage, + NostrKind::from(crate::config::constants::DM_EVENT_KIND), None, ) .await; @@ -862,18 +870,6 @@ mod tests { assert!(result.is_ok()); } - async fn create_migrated_ctx() -> AppContext { - let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); - sqlx::migrate!("./migrations") - .run(pool.as_ref()) - .await - .unwrap(); - TestContextBuilder::new() - .with_pool(pool) - .with_settings(test_settings()) - .build() - } - /// Insert a user row for `identity` with the given last_trade_index. async fn insert_user(ctx: &AppContext, identity: &PublicKey, index: i64) { add_new_user( @@ -1179,21 +1175,6 @@ mod tests { mod dispatch_cashu_tests { use super::*; - use crate::app::context::test_utils::{test_settings, TestContextBuilder}; - use sqlx::SqlitePool; - use std::sync::Arc; - - async fn create_ctx() -> AppContext { - let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap()); - sqlx::migrate!("./migrations") - .run(pool.as_ref()) - .await - .unwrap(); - TestContextBuilder::new() - .with_pool(pool) - .with_settings(test_settings()) - .build() - } fn is_invalid_action(result: Result<()>) -> bool { matches!( @@ -1214,7 +1195,7 @@ mod tests { let _ = crate::config::MOSTRO_CONFIG.set(crate::app::context::test_utils::test_settings()); let _ = crate::NOSTR_CLIENT.set(nostr_sdk::prelude::Client::default()); - let ctx = create_ctx().await; + let ctx = create_migrated_ctx().await; let my_keys = create_test_keys(); let event = create_test_unwrapped_message(); @@ -1249,7 +1230,7 @@ mod tests { /// returns `Ok` — proving it was NOT short-circuited to `InvalidAction`. #[tokio::test] async fn allows_restore_session_through_no_ln_router() { - let ctx = create_ctx().await; + let ctx = create_migrated_ctx().await; let my_keys = create_test_keys(); let event = create_test_unwrapped_message(); let msg = Message::new_restore(None); From 1e5ce457551827f3403ab19bbedf903a5b4a9184 Mon Sep 17 00:00:00 2001 From: Andrea Diaz Correia Date: Wed, 19 Aug 2026 15:10:49 -0300 Subject: [PATCH 3/3] docs: clarify v1 gift-wrap signature check and consolidate gate resolution --- docs/TRANSPORT_V2_SPEC.md | 5 ++- src/app.rs | 92 ++++++++++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md index 2aa8987a..39c01ca5 100644 --- a/docs/TRANSPORT_V2_SPEC.md +++ b/docs/TRANSPORT_V2_SPEC.md @@ -246,7 +246,10 @@ transport, whose outer key is a throwaway with no pre-validatable signal. per the "no reply for dropped events" rule the sender never learns why. Verifying first costs one Schnorr check on events the gate would have dropped, and still runs entirely before the NIP-44 decrypt the gate exists - to protect. + to protect. The check is transport-agnostic and applies to v1 gift wraps + too, where it is the daemon's *only* outer-event check: `unwrap_incoming` + re-verifies the event on the v2 path alone, while `nip59::unwrap_message` + verifies the seal's signature and never the outer wrap. - **Discoverability** (`src/nip33.rs`): the kind-38385 info event carries a `pow_first_contact` tag next to `pow`. A dropped event gets no `cant-do` reply — it never reaches a handler — so the info event is the only way a diff --git a/src/app.rs b/src/app.rs index ec557688..b6597816 100644 --- a/src/app.rs +++ b/src/app.rs @@ -335,8 +335,13 @@ async fn accept_event( // the victim's id. Verifying here (cheap Schnorr, pre-decrypt) is what // makes the spam gate's dedup safe: only ids that are provably the // author's own are ever recorded, so a forged copy cannot get the genuine - // event dropped as a replay. `unwrap_incoming` re-checks the signature for - // both transports; this is the daemon's own gate, not a substitute. + // event dropped as a replay. + // + // It is also the only outer-event check on the v1 path: `unwrap_incoming` + // re-verifies the event itself on v2 only (`unwrap_message_nip44` calls + // `event.verify()`), while v1's `nip59::unwrap_message` decrypts the wrap + // and verifies the *seal's* signature alone — never the outer gift wrap's + // id or signature. Do not delete this as redundant. if event.verify().is_err() { tracing::warn!("Dropping event {} with an invalid signature", event.id); return None; @@ -455,6 +460,24 @@ async fn finalize_dispatch( } } +/// Resolve the anti-spam gate for a transport, once per event loop. +/// +/// The gate applies to the v2 (kind-14) transport only: there the visible +/// author is the trade key, so the daemon can pre-validate before decrypting. +/// `None` fail-opens — v1 gift wraps (throwaway outer key, no pre-validatable +/// signal) or no gate installed. Shared by `run` and `run_cashu` so the single +/// v2-only policy cannot drift between the two loops. +/// +/// `install_spam_gate` (`main.rs`) runs before both loops, so the `OnceLock` +/// load is loop-invariant and stays out of the per-event path. +fn gate_for(is_v2: bool) -> Option<&'static SpamGate> { + if is_v2 { + SpamGate::global() + } else { + None + } +} + /// Main event loop that processes incoming Nostr events. /// Handles message verification, POW checking, and routes valid messages to appropriate handlers. /// @@ -479,15 +502,13 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { // clear `pow_first_contact`; known active-trade keys need only `pow`. The // gate is meaningless for v1 (gift wraps are signed by throwaway keys). let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact(); - let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND; + let gate = gate_for(accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND); loop { let mut notifications = client.notifications(); while let Some(notification) = notifications.next().await { if let ClientNotification::Event { event, .. } = notification { - // The gate is v2-only; `None` fail-opens (v1, or not installed). - let gate = if is_v2 { SpamGate::global() } else { None }; let Some((action, message, unwrapped)) = accept_event( &ctx, &event, @@ -532,15 +553,13 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> { #[allow(deprecated)] let accepted_kind = ctx.settings().mostro.transport.event_kind(); let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact(); - let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND; + let gate = gate_for(accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND); loop { let mut notifications = client.notifications(); while let Some(notification) = notifications.next().await { if let ClientNotification::Event { event, .. } = notification { - // The gate is v2-only; `None` fail-opens (v1, or not installed). - let gate = if is_v2 { SpamGate::global() } else { None }; let Some((action, message, unwrapped)) = accept_event( &ctx, &event, @@ -727,7 +746,7 @@ mod tests { mod accept_event_ordering_tests { use super::*; use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; - use mostro_core::nip59::WrapOptions; + use mostro_core::nip59::{wrap_message, WrapOptions}; use mostro_core::transport::wrap_message_nip44; /// A protocol-v2 (kind 14) event addressed to `mostro`, in full-privacy @@ -816,8 +835,7 @@ mod tests { #[tokio::test] async fn invalid_signature_is_dropped_without_a_gate() { - // v1 gift wraps and un-installed gates take the `None` path; the - // signature check must reject there too. + // The `None` path — no gate installed — must still reject. let ctx = create_migrated_ctx().await; let mostro = create_test_keys(); let forged = with_tampered_signature(&v2_event(&mostro)); @@ -834,6 +852,58 @@ mod tests { .await; assert!(accepted.is_none()); } + + /// A protocol-v1 gift wrap addressed to `mostro`, in full-privacy mode + /// (trade key doubles as identity). + async fn v1_event(mostro: &Keys) -> Event { + let trade = create_test_keys(); + let message = create_test_message(Action::FiatSent, None); + wrap_message( + &message, + &trade, + &trade, + mostro.public_key(), + WrapOptions::default(), + ) + .await + .expect("wrap gift wrap event") + } + + /// The v1 path takes `gate = None` and `nip59::unwrap_message` never + /// checks the outer event, so `accept_event`'s own `verify()` is the + /// only thing standing between a malformed gift wrap and the decrypt. + /// The second assertion is the one that pins the behaviour change: + /// well-formed gift wraps still go through. + #[tokio::test] + async fn v1_gift_wrap_with_invalid_signature_is_dropped() { + let ctx = create_migrated_ctx().await; + let mostro = create_test_keys(); + let genuine = v1_event(&mostro).await; + let forged = with_tampered_signature(&genuine); + + assert!( + accept_event(&ctx, &forged, &mostro, 0, 0, NostrKind::GiftWrap, None) + .await + .is_none(), + "a gift wrap with an invalid signature must be dropped" + ); + assert!( + accept_event(&ctx, &genuine, &mostro, 0, 0, NostrKind::GiftWrap, None) + .await + .is_some(), + "a well-formed gift wrap must still be accepted" + ); + } + + /// The v2-only policy lives in one place now; both event loops read it + /// from here, so this is where it gets covered. + #[test] + fn gate_applies_to_v2_only() { + assert!(gate_for(false).is_none(), "v1 must fail open"); + // `SpamGate::global()` is `None` unless `install_spam_gate` ran, so + // the v2 arm can only be pinned against the installed state. + assert_eq!(gate_for(true).is_some(), SpamGate::global().is_some()); + } } mod check_trade_index_tests {