From 9d3dab2a7d23c071f17b57a6c849d60b21d5be41 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Apr 2026 15:53:13 -0300 Subject: [PATCH 1/3] fix(nip59): authenticate daemon via seal identity, not rumor sender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check compared the active Mostro pubkey against the rumor sender, which is self-asserted whenever the inner signature is absent — the common case for protocol responses. An attacker could seal a wrap with their own key and set the rumor pubkey to the configured Mostro key, bypassing the check. Authenticate against `identity` (the seal signer, verified inside `unwrap_message`) and reject unsigned rumors whose `sender` diverges from the verified `identity`. --- rust/src/api/orders.rs | 47 ++++++++++++++++++++++++++----------- rust/src/nostr/gift_wrap.rs | 12 ++++++---- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 93a0a26c..09e80ef7 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1058,29 +1058,34 @@ async fn dispatch_mostro_message( ) { use mostro_core::message::Action; - // mostro-core 0.10 adds `identity` (seal signer, long-lived) alongside - // `sender` (rumor author, per-trade). A Mostro node that opts out of the - // identity/trade split — the current daemon — reuses the same key for - // both, so `identity == sender`. We keep the existing sender-based - // authentication check below; the extra `identity` field is deliberately - // ignored here. + // mostro-core 0.10 splits the wrap into two pubkeys: + // + // * `identity` — seal signer, proven by `seal.verify_signature()` in + // `unwrap_message`. This is the only field a forger cannot spoof. + // * `sender` — rumor author. Self-asserted unless `signature` is + // present and verifies against the rumor pubkey. + // + // Mostro protocol responses commonly omit the inner signature, so an + // attacker who seals a wrap with their own key can set the rumor pubkey + // to the configured Mostro key and route a forged message past any + // sender-based check. Authenticate against `identity` instead. let mostro_core::nip59::UnwrappedMessage { message: msg, sender, - identity: _, - signature: _, + identity, + signature, created_at: _, } = unwrapped; - // Daemon authentication: the active Mostro pubkey is the only legitimate - // sender for protocol responses. Reject anything else loudly — previously - // we trusted whatever decrypted under our trade key. + // Daemon authentication: the seal signer must be the active Mostro + // pubkey. The seal signature is verified inside `unwrap_message`, so + // `identity` is the cryptographically authoritative origin. match nostr_sdk::PublicKey::from_hex(&crate::config::active_mostro_pubkey()) { - Ok(expected) if expected == sender => {} + Ok(expected) if expected == identity => {} Ok(expected) => { crate::api::logging::blog_warn("gift-wrap", format!( - "rejecting gift-wrap: sender={} != active mostro={} (trade={})", - &sender.to_hex()[..8], + "rejecting gift-wrap: identity={} != active mostro={} (trade={})", + &identity.to_hex()[..8], &expected.to_hex()[..8], &trade_pubkey_hex[..8], )); @@ -1094,6 +1099,20 @@ async fn dispatch_mostro_message( } } + // The rumor pubkey is only trustworthy when the inner signature verifies + // it (checked inside `unwrap_message`). Without a signature, a `sender` + // that diverges from the verified `identity` is an unauthenticated claim + // — drop it rather than route a message whose origin we cannot prove. + if signature.is_none() && sender != identity { + crate::api::logging::blog_warn("gift-wrap", format!( + "rejecting gift-wrap: unsigned rumor with sender={} != identity={} (trade={})", + &sender.to_hex()[..8], + &identity.to_hex()[..8], + &trade_pubkey_hex[..8], + )); + return; + } + // Centralized response validation: catches malformed `request_id` fields // and flags `CantDo` responses. We pass `None` because the app does not // yet track outstanding request_ids per action (see issue #101 §5). diff --git a/rust/src/nostr/gift_wrap.rs b/rust/src/nostr/gift_wrap.rs index 678abed0..dcc5f6aa 100644 --- a/rust/src/nostr/gift_wrap.rs +++ b/rust/src/nostr/gift_wrap.rs @@ -62,11 +62,13 @@ pub async fn wrap_mostro_message( /// differ. The returned `UnwrappedMessage` exposes both (`identity` vs. /// `sender`) so callers can route and authorize accordingly. /// -/// Daemon authentication — verifying that `UnwrappedMessage.sender` equals -/// the active Mostro pubkey — is NOT performed here. The seal author is -/// proven via the inner signature but could in principle be any key; the -/// upstream dispatcher in `api/orders.rs` enforces the Mostro-pubkey check -/// before routing the message. +/// Daemon authentication is NOT performed here. The seal signature is +/// verified (so `identity` is cryptographically attributable) but the seal +/// signer could in principle be any key, and the rumor pubkey (`sender`) +/// is only meaningful when an inner signature accompanies it. The upstream +/// dispatcher in `api/orders.rs` enforces the Mostro-pubkey check against +/// `identity` — never against the unauthenticated `sender` — before +/// routing the message. pub async fn unwrap_mostro_message( trade_keys: &Keys, event: &Event, From 90d458b69b2763bb442e02a4c262d6a3bbb951ad Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Apr 2026 16:00:31 -0300 Subject: [PATCH 2/3] fix(nip59): drop unsigned-rumor sender/identity mismatch guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard rejected any unsigned wrap whose rumor sender differed from the seal identity. In Mostro's reputation-mode key split `sender` (per-trade key) and `identity` (long-lived seal key) are expected to differ, and protocol responses commonly omit the inner signature, so the check dropped legitimate daemon updates (order status, confirmations) even though the seal signature already authenticates the origin against the active Mostro pubkey. Authentication now relies solely on `identity == active_mostro_pubkey` — a forger who seals with their own key cannot make it match — and the unauthenticated `sender` is no longer used to gate dispatch. --- rust/src/api/orders.rs | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index 09e80ef7..a87c6de8 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1062,18 +1062,20 @@ async fn dispatch_mostro_message( // // * `identity` — seal signer, proven by `seal.verify_signature()` in // `unwrap_message`. This is the only field a forger cannot spoof. - // * `sender` — rumor author. Self-asserted unless `signature` is - // present and verifies against the rumor pubkey. + // * `sender` — rumor author. Self-asserted unless an inner + // `signature` is present and verifies against the rumor pubkey. // - // Mostro protocol responses commonly omit the inner signature, so an - // attacker who seals a wrap with their own key can set the rumor pubkey - // to the configured Mostro key and route a forged message past any - // sender-based check. Authenticate against `identity` instead. + // In Mostro's reputation-mode key split `sender` (per-trade key) and + // `identity` (long-lived seal key) are expected to differ, and protocol + // responses commonly omit the inner signature, so we cannot use a + // sender/identity equality check to gate dispatch. Authenticate against + // `identity` only — a forger who seals with their own key cannot make + // it match the configured Mostro pubkey. let mostro_core::nip59::UnwrappedMessage { message: msg, - sender, + sender: _, identity, - signature, + signature: _, created_at: _, } = unwrapped; @@ -1099,20 +1101,6 @@ async fn dispatch_mostro_message( } } - // The rumor pubkey is only trustworthy when the inner signature verifies - // it (checked inside `unwrap_message`). Without a signature, a `sender` - // that diverges from the verified `identity` is an unauthenticated claim - // — drop it rather than route a message whose origin we cannot prove. - if signature.is_none() && sender != identity { - crate::api::logging::blog_warn("gift-wrap", format!( - "rejecting gift-wrap: unsigned rumor with sender={} != identity={} (trade={})", - &sender.to_hex()[..8], - &identity.to_hex()[..8], - &trade_pubkey_hex[..8], - )); - return; - } - // Centralized response validation: catches malformed `request_id` fields // and flags `CantDo` responses. We pass `None` because the app does not // yet track outstanding request_ids per action (see issue #101 §5). From cc64ad3da6e3a6f5a2037afa3daef8669c61ff18 Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 25 Apr 2026 16:12:08 -0300 Subject: [PATCH 3/3] docs(nip59): correct dispatch_mostro_message docstring The docstring claimed authentication was performed against the rumor sender, but the implementation authenticates against the seal signer's identity. Update the wording to match: reference unwrap_mostro_message as the source of the verified unwrap and clarify that identity (never sender) is checked against the active Mostro pubkey. --- rust/src/api/orders.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/src/api/orders.rs b/rust/src/api/orders.rs index a87c6de8..d9ccc3bb 100644 --- a/rust/src/api/orders.rs +++ b/rust/src/api/orders.rs @@ -1048,7 +1048,11 @@ pub(crate) async fn subscribe_gift_wraps(trade_pubkey: nostr_sdk::PublicKey, tra /// Dispatch a Mostro `Message` recovered from a gift-wrap. /// -/// Authenticates the sender against the active Mostro pubkey, runs the +/// The caller recovers the `UnwrappedMessage` via +/// `crate::nostr::gift_wrap::unwrap_mostro_message`, which verifies the seal +/// signature so the `identity` field is cryptographically attributable. +/// This function authenticates that seal signer's `identity` against the +/// active Mostro pubkey (never the self-asserted rumor `sender`), runs the /// centralized `validate_response` check (catches `CantDo` responses and /// malformed `request_id` fields), then routes by action. async fn dispatch_mostro_message(