diff --git a/docs/MESSAGE_FLOW_AND_PROTOCOL.md b/docs/MESSAGE_FLOW_AND_PROTOCOL.md index 8375763a..d6dbc53e 100644 --- a/docs/MESSAGE_FLOW_AND_PROTOCOL.md +++ b/docs/MESSAGE_FLOW_AND_PROTOCOL.md @@ -20,8 +20,8 @@ Mostrix uses Nostr transports for two distinct purposes: Mostro daemons advertise wire format on the **instance status** event (kind **38385**): - Tag **`protocol_version`**: `"1"` → GiftWrap, `"2"` → NIP-44 direct messages. -- Mostrix parses this into [`MostroInstanceInfo.protocol_version`](../src/util/mostro_info.rs) and resolves [`Transport`](../src/util/mod.rs) with [`transport_from_instance`](../src/util/mostro_info.rs). -- [`AppState.transport`](../src/ui/app_state.rs) is kept in sync whenever instance info updates ([`set_mostro_info`](../src/ui/app_state.rs)). +- Mostrix parses this into [`MostroInstanceInfo.protocol_version`](../src/util/mostro_info.rs) and resolves [`Transport`](../src/util/mod.rs) with [`transport_from_instance`](../src/util/mostro_info.rs). Intake authenticates the kind-38385 event client-side ([`fetch_mostro_instance_info`](../src/util/mostro_info.rs) / MOSTRO-075). +- [`AppState.transport`](../src/ui/app_state.rs) is kept in sync when instance info updates ([`set_mostro_info`](../src/ui/app_state.rs); older `created_at` than the cache is ignored). - The **Mostro Info** tab displays protocol version and resolved wire transport. **Outbound send (implemented):** [`send_dm`](../src/util/dm_utils/mod.rs) uses `transport_from_instance` + [`wrap_message_with`](../src/util/mod.rs); v2 adds default NIP-40 expiration (30 days) when `expiration` is `None`. diff --git a/docs/POW_AND_OUTBOUND_EVENTS.md b/docs/POW_AND_OUTBOUND_EVENTS.md index 08969fd7..7266d77f 100644 --- a/docs/POW_AND_OUTBOUND_EVENTS.md +++ b/docs/POW_AND_OUTBOUND_EVENTS.md @@ -13,8 +13,8 @@ This document describes how Mostrix applies **NIP-13 proof-of-work** to events i ## Cached instance info at runtime -- [`AppState.mostro_info`](../src/ui/app_state.rs) holds the latest fetched `MostroInstanceInfo`. -- [`AppState.transport`](../src/ui/app_state.rs) mirrors resolved [`Transport`](../src/util/mod.rs). Updated via [`set_mostro_info`](../src/ui/app_state.rs). +- [`AppState.mostro_info`](../src/ui/app_state.rs) holds the latest **client-authenticated** `MostroInstanceInfo` (see [`fetch_mostro_instance_info`](../src/util/mostro_info.rs)). +- [`AppState.transport`](../src/ui/app_state.rs) mirrors resolved [`Transport`](../src/util/mod.rs). Updated via [`set_mostro_info`](../src/ui/app_state.rs) (stale `created_at` ignored). - [`EnterKeyContext`](../src/ui/key_handler/mod.rs) threads `mostro_info` into async work without re-fetching per message. - [`send_dm`](../src/util/dm_utils/mod.rs) takes `mostro_instance: Option<&MostroInstanceInfo>` and computes `pow = nostr_pow_for_protocol_dm(mostro_instance, action)` once per send. diff --git a/docs/STARTUP_AND_CONFIG.md b/docs/STARTUP_AND_CONFIG.md index c9a2c24b..ff703688 100644 --- a/docs/STARTUP_AND_CONFIG.md +++ b/docs/STARTUP_AND_CONFIG.md @@ -136,9 +136,10 @@ Proof-of-work for published events is taken from the Mostro instance status even Background and manual refresh (Mostro Info tab → Enter) fetch the daemon status event and update UI state: -- **`AppState.mostro_info`**: parsed tags (`pow`, `bond_enabled`, `protocol_version`, LND metadata, …) — see [`mostro_info_from_tags`](../src/util/mostro_info.rs). -- **`AppState.transport`**: resolved wire transport for **protocol DMs** via [`transport_from_instance`](../src/util/mostro_info.rs). Updated through [`AppState.set_mostro_info`](../src/ui/app_state.rs) (startup await, main loop `MostroInfoFetchResult`, reconnect, invalid-pubkey clear). +- **`AppState.mostro_info`**: parsed tags (`pow`, `bond_enabled`, `protocol_version`, LND metadata, …) via [`mostro_info_from_authenticated_event`](../src/util/mostro_info.rs) after client-side auth (tag-only parsing: [`mostro_info_from_tags`](../src/util/mostro_info.rs)). +- **`AppState.transport`**: resolved wire transport for **protocol DMs** via [`transport_from_instance`](../src/util/mostro_info.rs). Updated through [`AppState.set_mostro_info`](../src/ui/app_state.rs) (startup await, main loop `MostroInfoFetchResult`, reconnect, invalid-pubkey clear; ignores older `created_at` than the cache). - **Startup**: when relays are reachable, [`run_post_terminal_startup`](../src/startup.rs) **awaits** [`fetch_mostro_instance_info`](../src/util/mostro_info.rs) before spawning the DM listener so the first subscription uses the correct transport (v1 GiftWrap or v2 kind 14). On fetch failure or offline boot, transport defaults to GiftWrap. +- **Auth (MOSTRO-075)**: relay `.author()` filters are not trusted. [`fetch_mostro_instance_info`](../src/util/mostro_info.rs) re-checks `event.pubkey`, the `d` tag, and `Event::verify` via [`instance_info_event_is_authentic`](../src/util/mostro_info.rs) / [`select_authentic_instance_info_event`](../src/util/mostro_info.rs) before apply. [`MostroInstanceInfoFetch::Rejected`](../src/util/mostro_info.rs) / `NotFound` preserve the cached revision (no `set_mostro_info(None)`); relay fetch failures use [`MostroInfoFetchResult::FetchFailed`](../src/ui/orders.rs) (same). Explicit clears stay on hard [`MostroInfoFetchResult::Err`](../src/ui/orders.rs) (invalid settings/pubkey). Displayed on the **Mostro Info** tab: protocol version (`1` / `2` / unknown) and wire transport label (GiftWrap vs NIP-44 direct). diff --git a/src/main.rs b/src/main.rs index b9e77515..5a02bc6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -661,7 +661,7 @@ async fn main() -> Result<(), anyhow::Error> { match res { MostroInfoFetchResult::Ok { info, message } => { let old_transport = app.transport; - app.set_mostro_info(*info); + app.set_mostro_info(Some(*info)); if old_transport != app.transport { log::warn!( "Mostro protocol transport changed {:?} -> {:?}; restarting DM listener", @@ -689,8 +689,16 @@ async fn main() -> Result<(), anyhow::Error> { crate::ui::OperationResult::Info(message), ); } - MostroInfoFetchResult::Applied { info } => { - app.set_mostro_info(*info); + MostroInfoFetchResult::NotFound { message } + | MostroInfoFetchResult::Rejected { message } => { + app.mode = crate::ui::UiMode::operation_result( + crate::ui::OperationResult::Info(message), + ); + } + MostroInfoFetchResult::FetchFailed { message } => { + app.mode = crate::ui::UiMode::operation_result( + crate::ui::OperationResult::Error(message), + ); } MostroInfoFetchResult::Err(e) => { app.set_mostro_info(None); diff --git a/src/startup.rs b/src/startup.rs index 1b510b31..3b23fccb 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -214,16 +214,17 @@ pub async fn run_post_terminal_startup( let transport_for_listener = if relays_reachable { set_startup_phase(phase_tx, "Fetching Mostro instance info…"); match fetch_mostro_instance_info(&client, mostro_pubkey).await { - Ok(info) => { - app.set_mostro_info(info); + Ok(fetch) => { + if let Some(info) = fetch.to_apply() { + app.set_mostro_info(Some(info)); + } app.transport } Err(e) => { log::warn!( - "Failed to fetch Mostro instance info at startup: {e}; defaulting to GiftWrap transport" + "Failed to fetch Mostro instance info at startup: {e}; keeping cached/default transport" ); - app.set_mostro_info(None); - Transport::default() + app.transport } } } else { diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 5e6f86c6..ab198e6c 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -291,9 +291,11 @@ pub struct AppState { pub pending_admin_disputes_reload: bool, /// Cached copy of currencies filter from settings (used for UI-side filtering). pub currencies_filter: Vec, - /// Cached Mostro instance info (kind 38385 event), if available. + /// Cached Mostro instance info (kind 38385), if available. + /// Populated only from client-authenticated fetches; apply via [`Self::set_mostro_info`]. pub mostro_info: Option, /// Wire transport resolved from [`Self::mostro_info`] (`protocol_version` tag). + /// Kept in sync by [`Self::set_mostro_info`] (including stale-revision ignore). pub transport: Transport, /// Non-blocking overlay shown when relays are unreachable. pub offline_overlay_message: Option, @@ -401,7 +403,22 @@ impl AppState { } /// Replace cached instance info and keep [`Self::transport`] in sync. + /// + /// Fail-closed on stale revisions: when both the incoming and cached values carry + /// `last_updated`, an older `created_at` is ignored so a lagging or malicious relay + /// cannot roll transport / fee / PoW display back (MOSTRO-075 monotonicity). + /// Explicit `None` still clears (invalid pubkey, hard fetch errors). pub fn set_mostro_info(&mut self, info: Option) { + if let (Some(new), Some(old)) = (info.as_ref(), self.mostro_info.as_ref()) { + if let (Some(new_ts), Some(old_ts)) = (new.last_updated, old.last_updated) { + if new_ts < old_ts { + log::warn!( + "Ignoring stale Mostro instance info (created_at {new_ts} < cached {old_ts})" + ); + return; + } + } + } self.transport = transport_from_instance(info.as_ref()); self.mostro_info = info; } @@ -477,9 +494,12 @@ impl AppState { } #[cfg(test)] +#[allow(deprecated)] mod tests { use super::*; use crate::ui::chat::{ChatSender, DisputeChatMessage}; + use mostro_core::prelude::Transport; + use nostr_sdk::prelude::Timestamp; fn dummy_observer_message(content: &str) -> DisputeChatMessage { DisputeChatMessage { @@ -521,4 +541,42 @@ mod tests { assert!(app.observer_messages.is_empty()); assert!(!app.observer_loading); } + + /// MOSTRO-075: stale created_at must not roll transport back. + #[test] + fn set_mostro_info_rejects_older_created_at() { + let mut app = AppState::new(UserRole::User); + let newer = MostroInstanceInfo { + last_updated: Some(Timestamp::from(2_000)), + protocol_version: Some(2), + ..Default::default() + }; + app.set_mostro_info(Some(newer)); + assert_eq!(app.transport, Transport::Nip44Direct); + + let older = MostroInstanceInfo { + last_updated: Some(Timestamp::from(1_000)), + protocol_version: Some(1), + ..Default::default() + }; + app.set_mostro_info(Some(older)); + assert_eq!(app.transport, Transport::Nip44Direct); + assert_eq!( + app.mostro_info.as_ref().and_then(|i| i.protocol_version), + Some(2) + ); + } + + #[test] + fn set_mostro_info_none_still_clears() { + let mut app = AppState::new(UserRole::User); + app.set_mostro_info(Some(MostroInstanceInfo { + last_updated: Some(Timestamp::from(2_000)), + protocol_version: Some(2), + ..Default::default() + })); + app.set_mostro_info(None); + assert!(app.mostro_info.is_none()); + assert_eq!(app.transport, Transport::GiftWrap); + } } diff --git a/src/ui/key_handler/async_tasks.rs b/src/ui/key_handler/async_tasks.rs index cd1342bb..84907d12 100644 --- a/src/ui/key_handler/async_tasks.rs +++ b/src/ui/key_handler/async_tasks.rs @@ -236,6 +236,9 @@ fn clear_runtime_tracking_state_preserve_messages(app: &mut AppState) { } /// Fetch instance info for `mostro_pubkey`, refresh [`AppState`] transport, and return it for the DM listener. +/// +/// Uses [`fetch_mostro_instance_info`] (client-side author / `d` / signature checks). Apply goes +/// through [`AppState::set_mostro_info`], which ignores older `created_at` than the cache. async fn dm_transport_for_mostro( client: &Client, mostro_pubkey: PublicKey, @@ -243,16 +246,17 @@ async fn dm_transport_for_mostro( log_context: &str, ) -> Transport { match fetch_mostro_instance_info(client, mostro_pubkey).await { - Ok(info) => { - app.set_mostro_info(info); + Ok(fetch) => { + if let Some(info) = fetch.to_apply() { + app.set_mostro_info(Some(info)); + } app.transport } Err(e) => { log::warn!( - "{log_context}: failed to fetch Mostro instance info: {e}; defaulting to GiftWrap transport" + "{log_context}: failed to fetch Mostro instance info: {e}; keeping cached transport" ); - app.set_mostro_info(None); - Transport::default() + app.transport } } } @@ -1152,60 +1156,61 @@ pub fn spawn_refresh_mostro_info_from_settings_task( }; let result = fetch_mostro_instance_info(&client, mostro_pubkey).await; let res = match result { - Ok(Some(info)) => MostroInfoFetchResult::Ok { - info: Box::new(Some(info)), + Ok(crate::util::MostroInstanceInfoFetch::Found(info)) => MostroInfoFetchResult::Ok { + info, message: "Mostro instance info refreshed from relays.".to_string(), }, - Ok(None) => MostroInfoFetchResult::Ok { - info: Box::new(None), + Ok(crate::util::MostroInstanceInfoFetch::NotFound) => MostroInfoFetchResult::NotFound { message: "No Mostro instance info event found for the current pubkey.".to_string(), }, - Err(e) => { - MostroInfoFetchResult::Err(format!("Failed to refresh Mostro instance info: {}", e)) + Ok(crate::util::MostroInstanceInfoFetch::Rejected { fetched }) => { + MostroInfoFetchResult::Rejected { + message: format!( + "Rejected {fetched} unauthentic instance-info event(s); keeping cached settings." + ), + } } + Err(e) => MostroInfoFetchResult::FetchFailed { + message: format!("Failed to refresh Mostro instance info: {}", e), + }, }; let _ = tx.send(res); }); } -/// `show_result_toast`: when false (e.g. startup), only [`MostroInfoFetchResult::Applied`] is sent on -/// success and errors are logged without UI. +/// Refresh instance info after the configured Mostro pubkey changes. +/// +/// Sends [`MostroInfoFetchResult`] to the main loop (UI toast + optional DM listener respawn). pub fn spawn_refresh_mostro_info_task( client: Client, mostro_pubkey: PublicKey, tx: UnboundedSender, - show_result_toast: bool, ) { tokio::spawn(async move { let result = fetch_mostro_instance_info(&client, mostro_pubkey).await; - if !show_result_toast { - match &result { - Ok(Some(_)) => {} - Ok(None) => { - log::info!("No Mostro instance info event found for current Mostro pubkey"); - } - Err(e) => { - log::warn!("Failed to fetch Mostro instance info: {}", e); - } - } - if let Ok(info) = result { - let _ = tx.send(MostroInfoFetchResult::Applied { - info: Box::new(info), - }); - } - return; - } let res = match result { - Ok(info) => MostroInfoFetchResult::Ok { - info: Box::new(info), + Ok(crate::util::MostroInstanceInfoFetch::Found(info)) => MostroInfoFetchResult::Ok { + info, message: "Mostro instance info updated.".to_string(), }, + Ok(crate::util::MostroInstanceInfoFetch::NotFound) => MostroInfoFetchResult::NotFound { + message: "No Mostro instance info event found for the current pubkey.".to_string(), + }, + Ok(crate::util::MostroInstanceInfoFetch::Rejected { fetched }) => { + MostroInfoFetchResult::Rejected { + message: format!( + "Rejected {fetched} unauthentic instance-info event(s); keeping cached settings." + ), + } + } Err(e) => { log::warn!( "Failed to refresh Mostro instance info after pubkey change: {}", e ); - MostroInfoFetchResult::Err(e.to_string()) + MostroInfoFetchResult::FetchFailed { + message: format!("Failed to refresh Mostro instance info: {}", e), + } } }; let _ = tx.send(res); diff --git a/src/ui/key_handler/enter_handlers.rs b/src/ui/key_handler/enter_handlers.rs index 4f64164a..4b1524c7 100644 --- a/src/ui/key_handler/enter_handlers.rs +++ b/src/ui/key_handler/enter_handlers.rs @@ -1055,7 +1055,6 @@ fn handle_enter_settings_mode( ctx.client.clone(), new_pubkey, ctx.mostro_info_tx.clone(), - true, ); app.mode = UiMode::operation_result(OperationResult::Info( "Fetching Mostro instance info...".to_string(), diff --git a/src/ui/orders.rs b/src/ui/orders.rs index 0d72af1d..905154a1 100644 --- a/src/ui/orders.rs +++ b/src/ui/orders.rs @@ -247,14 +247,18 @@ pub enum LnAddressVerifyResult { /// Result of an async Mostro instance info fetch (sent from key handlers to main loop). #[derive(Clone, Debug)] pub enum MostroInfoFetchResult { + /// Authentic revision fetched; apply via `set_mostro_info(Some(..))`. Ok { - info: Box>, + info: Box, message: String, }, - /// Startup / background refresh: update `mostro_info` only; do not change mode or show toasts. - Applied { - info: Box>, - }, + /// No kind-38385 event on relay; cached instance info is unchanged. + NotFound { message: String }, + /// Relay returned unauthentic candidates; cached instance info is unchanged. + Rejected { message: String }, + /// Relay fetch failed (timeout / unreachable); cached instance info is unchanged. + FetchFailed { message: String }, + /// Hard failure (invalid settings / pubkey); clears cached instance info. Err(String), } diff --git a/src/util/mod.rs b/src/util/mod.rs index 5f763635..4669b926 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -51,8 +51,10 @@ pub use filters::{ pub use mostro_core::prelude::{unwrap_incoming, wrap_message_with, Transport}; pub use mostro_info::{ fetch_mostro_instance_info, fetch_mostro_instance_info_from_settings, format_instance_info_age, - instance_bonds_enabled, mostro_info_from_tags, nostr_pow_from_instance, - transport_from_instance, MostroInstanceInfo, MOSTRO_INSTANCE_INFO_KIND, + instance_bonds_enabled, instance_info_event_is_authentic, mostro_info_from_authenticated_event, + mostro_info_from_tags, nostr_pow_from_instance, select_authentic_instance_info_event, + transport_from_instance, MostroInstanceInfo, MostroInstanceInfoFetch, + MOSTRO_INSTANCE_INFO_KIND, }; pub use network::{any_relay_reachable, connect_client_safely}; pub use order_utils::{ diff --git a/src/util/mostro_info.rs b/src/util/mostro_info.rs index 5db7696f..f03b3341 100644 --- a/src/util/mostro_info.rs +++ b/src/util/mostro_info.rs @@ -8,6 +8,32 @@ use std::str::FromStr; /// Nostr kind for Mostro instance status events. pub const MOSTRO_INSTANCE_INFO_KIND: u16 = 38385; +/// Outcome of fetching kind-38385 instance info from relays. +/// +/// Distinguishes “no candidates” from “candidates rejected by client-side auth” so callers +/// preserve the last trusted cache instead of treating both as [`None`] clears (MOSTRO-075). +#[derive(Clone, Debug)] +pub enum MostroInstanceInfoFetch { + /// Newest authentic revision parsed from relay data. + Found(Box), + /// Relay returned no kind-38385 candidates for the filter. + NotFound, + /// Relay returned one or more candidates but none passed [`instance_info_event_is_authentic`]. + Rejected { fetched: usize }, +} + +impl MostroInstanceInfoFetch { + /// Returns `Some(info)` when callers should invoke `AppState::set_mostro_info(Some(info))`. + /// + /// `None` means **preserve** the existing cache — do not call `set_mostro_info(None)`. + pub fn to_apply(self) -> Option { + match self { + Self::Found(info) => Some(*info), + Self::NotFound | Self::Rejected { .. } => None, + } + } +} + /// Age in seconds after which instance info is considered stale (7 days). const INSTANCE_INFO_STALE_SECS: u64 = 604_800; @@ -33,7 +59,8 @@ pub fn format_instance_info_age(ts: &Timestamp) -> String { /// All fields are optional because different instances may omit some tags. #[derive(Clone, Debug, Default)] pub struct MostroInstanceInfo { - /// When the instance info event was created (set by fetch, not from tags). + /// When the instance info event was created (set at fetch parse time, not from tags). + /// Used by [`crate::ui::AppState::set_mostro_info`] to reject older revisions. pub last_updated: Option, pub mostro_version: Option, pub mostro_commit_hash: Option, @@ -175,10 +202,61 @@ fn parse_bond_enabled(value: &str) -> Option { } } +/// Whether a relay-returned kind-38385 event is safe to treat as Mostro instance info. +/// +/// Relays can ignore or forge responses to `.author()` filters (MOSTRO-075). Before applying +/// `protocol_version` / fees / PoW, require: +/// - kind 38385 +/// - `event.pubkey == mostro_pubkey` (client-side author re-check) +/// - `d` tag identifier equals the Mostro pubkey hex (addressable coordinate) +/// - valid event id + signature (`Event::verify`) +pub fn instance_info_event_is_authentic(event: &Event, mostro_pubkey: PublicKey) -> bool { + if event.kind != Kind::Custom(MOSTRO_INSTANCE_INFO_KIND) { + return false; + } + if event.pubkey != mostro_pubkey { + return false; + } + let expected_d = mostro_pubkey.to_string(); + match event.tags.identifier() { + Some(id) if id == expected_d => {} + _ => return false, + } + event.verify().is_ok() +} + +/// Pick the newest authentic instance-info event from a relay fetch result. +/// +/// Skips forged / wrong-author / bad-signature events. Prefer this over trusting +/// `limit(1)` alone — a malicious relay can return a throwaway-signed newer event first. +pub fn select_authentic_instance_info_event( + events: impl IntoIterator, + mostro_pubkey: PublicKey, +) -> Option { + events + .into_iter() + .filter(|e| instance_info_event_is_authentic(e, mostro_pubkey)) + .max_by_key(|e| e.created_at) +} + +/// Parse instance info from a kind-38385 event that was already selected as authentic. +/// +/// Sets [`MostroInstanceInfo::last_updated`] from `event.created_at`. Does **not** call +/// [`instance_info_event_is_authentic`] — use after [`select_authentic_instance_info_event`] +/// (or equivalent checks). +pub fn mostro_info_from_authenticated_event(event: &Event) -> Result { + let mut info = mostro_info_from_tags(event.tags.clone())?; + info.last_updated = Some(event.created_at); + Ok(info) +} + /// Build a `MostroInstanceInfo` from the tags of a kind 38385 event. /// /// Unknown tags are ignored. Missing tags simply leave the corresponding /// fields as `None` or empty collections. +/// +/// Does **not** authenticate authorship — callers that apply transport / fees from +/// relay data must use [`select_authentic_instance_info_event`] first. pub fn mostro_info_from_tags(tags: Tags) -> Result { let mut info = MostroInstanceInfo::default(); @@ -270,19 +348,24 @@ pub fn mostro_info_from_tags(tags: Tags) -> Result { /// Fetch the latest Mostro instance info event for the given Mostro pubkey. /// -/// Filters on: -/// - kind 38385 (Mostro instance status) -/// - author = Mostro pubkey -/// - `d` tag / identifier = Mostro pubkey +/// Relay filter uses author + kind + `d` tag, but relays are not trusted: results are +/// re-checked client-side ([`instance_info_event_is_authentic`]) before apply +/// (MOSTRO-075 — forged newer events must not flip `protocol_version` / transport). +/// +/// Returns [`MostroInstanceInfoFetch::NotFound`] when the relay returns no events, +/// [`MostroInstanceInfoFetch::Rejected`] when every candidate fails authenticity checks. +/// Fetches up to 10 candidates and keeps the newest authentic revision by `created_at`. pub async fn fetch_mostro_instance_info( client: &Client, mostro_pubkey: PublicKey, -) -> Result> { +) -> Result { + // Ask for a small window so a forged "newest" event cannot wholly hide an + // authentic revision when the relay returns a mixed set. let filter = Filter::new() .author(mostro_pubkey) - .kind(nostr_sdk::prelude::Kind::Custom(MOSTRO_INSTANCE_INFO_KIND)) + .kind(Kind::Custom(MOSTRO_INSTANCE_INFO_KIND)) .identifier(mostro_pubkey.to_string()) - .limit(1); + .limit(10); let events = client .fetch_events(filter) @@ -290,21 +373,28 @@ pub async fn fetch_mostro_instance_info( .await .map_err(|e| anyhow::anyhow!("Failed to fetch Mostro instance info from relays: {}", e))?; - let event = match events.iter().next() { - Some(ev) => ev, - None => return Ok(None), + let fetched = events.len(); + let Some(event) = select_authentic_instance_info_event(events, mostro_pubkey) else { + if fetched > 0 { + log::warn!( + "Rejected {fetched} kind-{MOSTRO_INSTANCE_INFO_KIND} event(s): none matched Mostro pubkey {mostro_pubkey} with a valid signature and d-tag" + ); + return Ok(MostroInstanceInfoFetch::Rejected { fetched }); + } + return Ok(MostroInstanceInfoFetch::NotFound); }; - let mut info = mostro_info_from_tags(event.tags.clone())?; - info.last_updated = Some(event.created_at); - Ok(Some(info)) + Ok(MostroInstanceInfoFetch::Found(Box::new( + mostro_info_from_authenticated_event(&event)?, + ))) } /// Convenience helper: load the latest settings from disk, parse the configured -/// Mostro pubkey, and fetch instance info for that pubkey from the relays. +/// Mostro pubkey, and fetch instance info for that pubkey from the relays +/// (same client-side authenticity checks as [`fetch_mostro_instance_info`]). pub async fn fetch_mostro_instance_info_from_settings( client: &Client, -) -> Result> { +) -> Result { let settings = load_settings_from_disk().map_err(|e| anyhow!("Failed to load settings: {}", e))?; let mostro_pubkey = PublicKey::from_str(&settings.mostro_pubkey) @@ -317,7 +407,7 @@ pub async fn fetch_mostro_instance_info_from_settings( mod tests { use super::*; use mostro_core::prelude::Transport; - use nostr_sdk::prelude::{Tag, Tags}; + use nostr_sdk::prelude::{Event, EventBuilder, Keys, Kind, Tag, Tags, Timestamp}; #[test] fn split_csv_trims_and_ignores_empty_values() { @@ -577,10 +667,114 @@ mod tests { assert_eq!(nostr_pow_for_protocol_dm(Some(&info), &Action::NewOrder), 8); } - // Fetch tests would require a mock or test double for nostr_sdk::Client - // (e.g. a trait + impl for production and a mock that returns empty events - // or simulates timeout). Not implemented here to avoid refactoring the public API. - // Intended behavior: - // - fetch_returns_none_when_no_event_exists: client returns []; assert Ok(None) - // - fetch_handles_network_timeout: client returns/timeouts with error; assert Err + fn instance_info_event( + keys: &Keys, + protocol_version: &str, + d_tag: Option, + created_at: u64, + ) -> Event { + let mut tags = vec![ + Tag::parse(["protocol_version", protocol_version]).unwrap(), + Tag::parse(["fee", "0.0"]).unwrap(), + Tag::parse(["pow", "0"]).unwrap(), + ]; + if let Some(d) = d_tag { + tags.push(Tag::identifier(d)); + } + EventBuilder::new(Kind::Custom(MOSTRO_INSTANCE_INFO_KIND), "") + .tags(Tags::from_list(tags)) + .custom_created_at(Timestamp::from(created_at)) + .finalize(keys) + .expect("instance info event") + } + + /// MOSTRO-075: a throwaway-signed newer kind-38385 must not authenticate. + #[test] + fn forged_author_instance_info_is_rejected() { + let mostro = Keys::generate(); + let attacker = Keys::generate(); + let forged = + instance_info_event(&attacker, "2", Some(mostro.public_key().to_string()), 2_000); + assert!(!instance_info_event_is_authentic( + &forged, + mostro.public_key() + )); + assert!( + select_authentic_instance_info_event(vec![forged], mostro.public_key()).is_none(), + "forged author must not win newest-event selection" + ); + } + + #[test] + fn wrong_d_tag_instance_info_is_rejected() { + let mostro = Keys::generate(); + let bad_d = instance_info_event(&mostro, "2", Some("not-the-mostro-pubkey".into()), 1_000); + assert!(!instance_info_event_is_authentic( + &bad_d, + mostro.public_key() + )); + } + + #[test] + fn missing_d_tag_instance_info_is_rejected() { + let mostro = Keys::generate(); + let no_d = instance_info_event(&mostro, "2", None, 1_000); + assert!(!instance_info_event_is_authentic( + &no_d, + mostro.public_key() + )); + } + + #[test] + fn authentic_instance_info_wins_over_forged_newer() { + let mostro = Keys::generate(); + let attacker = Keys::generate(); + let authentic = + instance_info_event(&mostro, "1", Some(mostro.public_key().to_string()), 1_000); + let forged_newer = + instance_info_event(&attacker, "2", Some(mostro.public_key().to_string()), 9_999); + + let selected = select_authentic_instance_info_event( + vec![forged_newer, authentic.clone()], + mostro.public_key(), + ) + .expect("authentic event must be selected"); + assert_eq!(selected.id, authentic.id); + let info = mostro_info_from_authenticated_event(&selected).unwrap(); + assert_eq!(info.protocol_version, Some(1)); + assert_eq!(info.last_updated, Some(Timestamp::from(1_000))); + assert_eq!(transport_from_instance(Some(&info)), Transport::GiftWrap); + } + + #[test] + fn newest_authentic_instance_info_is_selected() { + let mostro = Keys::generate(); + let older = instance_info_event(&mostro, "1", Some(mostro.public_key().to_string()), 1_000); + let newer = instance_info_event(&mostro, "2", Some(mostro.public_key().to_string()), 2_000); + let selected = + select_authentic_instance_info_event(vec![older, newer.clone()], mostro.public_key()) + .expect("newest authentic"); + assert_eq!(selected.id, newer.id); + let info = mostro_info_from_authenticated_event(&selected).unwrap(); + assert_eq!(info.protocol_version, Some(2)); + assert_eq!(transport_from_instance(Some(&info)), Transport::Nip44Direct); + } + + #[test] + fn instance_info_fetch_to_apply_only_on_found() { + assert!(MostroInstanceInfoFetch::Found(Box::new(MostroInstanceInfo { + protocol_version: Some(2), + ..Default::default() + })) + .to_apply() + .is_some()); + assert!(MostroInstanceInfoFetch::NotFound.to_apply().is_none()); + assert!(MostroInstanceInfoFetch::Rejected { fetched: 3 } + .to_apply() + .is_none()); + } + + // Auth selection (MOSTRO-075) is covered above without a Client. Full + // `fetch_mostro_instance_info` I/O still needs a mock/test double for + // nostr_sdk::Client (empty set → NotFound; forged set → Rejected; timeout → Err). }