diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/Cargo.lock b/Cargo.lock index 6e87ff0..8fc0078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1550,9 +1550,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47931c8de17481b95e05f2cac0ac8a077247a040631a929bbae656bfb48fc4e2" +checksum = "d76f4936f520e410ba2abf47113548ae231322209261a9b3a8aefbd10a869082" dependencies = [ "bitcoin", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ce6ae4a..2fe36bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ reqwest = { version = "0.12.23", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = "0.9.1" +mostro-core = "0.10.0" lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] } pretty_env_logger = "0.5.0" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio-rustls"] } diff --git a/src/cli/add_invoice.rs b/src/cli/add_invoice.rs index 2bce349..91d706f 100644 --- a/src/cli/add_invoice.rs +++ b/src/cli/add_invoice.rs @@ -75,6 +75,7 @@ pub async fn execute_add_invoice(order_id: &Uuid, invoice: &str, ctx: &Context) // Send the DM let sent_message = send_dm( &ctx.client, + &ctx.identity_keys, &order_trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/adm_send_dm.rs b/src/cli/adm_send_dm.rs index bfb56de..c2d5bb1 100644 --- a/src/cli/adm_send_dm.rs +++ b/src/cli/adm_send_dm.rs @@ -29,7 +29,7 @@ pub async fn execute_adm_send_dm(receiver: PublicKey, ctx: &Context, message: &s println!("{table}"); println!("💡 Sending admin gift wrap message...\n"); - send_plain_text_dm(&ctx.client, admin_keys, &receiver, message).await?; + send_plain_text_dm(&ctx.client, admin_keys, admin_keys, &receiver, message).await?; println!( "✅ Admin gift wrap message sent successfully to {}", diff --git a/src/cli/last_trade_index.rs b/src/cli/last_trade_index.rs index e61e734..bdf5746 100644 --- a/src/cli/last_trade_index.rs +++ b/src/cli/last_trade_index.rs @@ -24,12 +24,13 @@ pub async fn execute_last_trade_index( .map_err(|_| anyhow::anyhow!("Failed to serialize message"))?; // LastTradeIndex is account-scoped: the answer depends on which user - // is asking, and Mostro looks that up by the sender pubkey. Sign with - // `identity_keys` so the request resolves to the account, not to a - // (possibly unregistered) trade key. + // is asking, and Mostro looks that up by the sender pubkey. Sign both + // seal and rumor with `identity_keys` so the request resolves to the + // account, not to a (possibly unregistered) trade key. let sent_message = send_dm( &ctx.client, identity_keys, + identity_keys, &mostro_key, message_json, None, diff --git a/src/cli/new_order.rs b/src/cli/new_order.rs index 7cb5402..524f164 100644 --- a/src/cli/new_order.rs +++ b/src/cli/new_order.rs @@ -183,6 +183,7 @@ pub async fn execute_new_order( // Send the DM let sent_message = send_dm( &ctx.client, + &ctx.identity_keys, &ctx.trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/orders_info.rs b/src/cli/orders_info.rs index 9abd13a..9b061fb 100644 --- a/src/cli/orders_info.rs +++ b/src/cli/orders_info.rs @@ -27,14 +27,10 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<() // Create payload with the order IDs let payload = Payload::Ids(order_ids.to_vec()); - // Create message using the proper Message structure - let message = Message::new_order( - None, - Some(request_id), - Some(ctx.trade_index), - Action::Orders, - Some(payload), - ); + // Orders info is account-scoped — Mostro indexes users by their identity + // pubkey, so the message carries no trade_index and the whole exchange + // (send, wait, decrypt) runs on `identity_keys`. + let message = Message::new_order(None, Some(request_id), None, Action::Orders, Some(payload)); // Serialize the message let message_json = message @@ -44,7 +40,8 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<() // Send the DM let sent_message = send_dm( &ctx.client, - &ctx.trade_keys, + &ctx.identity_keys, + &ctx.identity_keys, &ctx.mostro_pubkey, message_json, None, @@ -52,10 +49,10 @@ pub async fn execute_orders_info(order_ids: &[Uuid], ctx: &Context) -> Result<() ); // Wait for the DM response from mostro - let recv_event = wait_for_dm(ctx, None, sent_message).await?; + let recv_event = wait_for_dm(ctx, Some(&ctx.identity_keys), sent_message).await?; // Parse the incoming DM and handle the response - let messages = crate::parser::dms::parse_dm_events(recv_event, &ctx.trade_keys, None).await; + let messages = crate::parser::dms::parse_dm_events(recv_event, &ctx.identity_keys, None).await; if let Some((message, _, _)) = messages.first() { let message_kind = message.get_inner_message_kind(); diff --git a/src/cli/rate_user.rs b/src/cli/rate_user.rs index 8f1f41f..ad3ca51 100644 --- a/src/cli/rate_user.rs +++ b/src/cli/rate_user.rs @@ -60,6 +60,7 @@ pub async fn execute_rate_user(order_id: &Uuid, rating: &u8, ctx: &Context) -> R let sent_message = send_dm( &ctx.client, + &ctx.identity_keys, &trade_keys, &ctx.mostro_pubkey, rate_message, diff --git a/src/cli/restore.rs b/src/cli/restore.rs index cf61a69..c913ae0 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -22,10 +22,13 @@ pub async fn execute_restore( // Restore is account-scoped: Mostro indexes users by their identity // pubkey, so the whole exchange (send, wait, decrypt) runs on // `identity_keys` — an unregistered trade key would look like an - // unknown user and recovery would silently return nothing. + // unknown user and recovery would silently return nothing. With the + // mostro-core 0.10 dual-key split we pass `identity_keys` as both + // the seal signer and the rumor author. let sent_message = send_dm( &ctx.client, identity_keys, + identity_keys, &mostro_key, message_json, None, diff --git a/src/cli/send_dm.rs b/src/cli/send_dm.rs index 9742868..1a77bbe 100644 --- a/src/cli/send_dm.rs +++ b/src/cli/send_dm.rs @@ -53,7 +53,16 @@ pub async fn execute_send_dm( return Err(anyhow::anyhow!("order {} not found", order_id)); }; - send_dm(&ctx.client, &trade_keys, &receiver, message, None, false).await?; + send_dm( + &ctx.client, + &ctx.identity_keys, + &trade_keys, + &receiver, + message, + None, + false, + ) + .await?; println!("✅ Direct message sent successfully!"); diff --git a/src/cli/send_msg.rs b/src/cli/send_msg.rs index 32acd64..4fd6e30 100644 --- a/src/cli/send_msg.rs +++ b/src/cli/send_msg.rs @@ -99,6 +99,7 @@ pub async fn execute_send_msg( // Send DM let sent_message = send_dm( &ctx.client, + &ctx.identity_keys, &trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/cli/take_dispute.rs b/src/cli/take_dispute.rs index 68bcaf9..a8e2a85 100644 --- a/src/cli/take_dispute.rs +++ b/src/cli/take_dispute.rs @@ -141,9 +141,12 @@ pub async fn execute_take_dispute(dispute_id: &Uuid, ctx: &Context) -> Result<() // Send the dispute message and wait for response. Admin identity // binds via the rumor/seal/inner-signature produced from `admin_keys`. + // The admin role doesn't rotate trade keys, so the same key signs both + // the seal and the rumor (full-privacy-style wrap). let sent_message = send_dm( &ctx.client, admin_keys, + admin_keys, &ctx.mostro_pubkey, take_dispute_message, None, diff --git a/src/cli/take_order.rs b/src/cli/take_order.rs index a54d780..e064a2e 100644 --- a/src/cli/take_order.rs +++ b/src/cli/take_order.rs @@ -124,6 +124,7 @@ pub async fn execute_take_order( // This is so we can wait for the gift wrap event in the main thread let sent_message = send_dm( &ctx.client, + &ctx.identity_keys, &ctx.trade_keys, &ctx.mostro_pubkey, message_json, diff --git a/src/util/messaging.rs b/src/util/messaging.rs index 80ec448..6b61ea4 100644 --- a/src/util/messaging.rs +++ b/src/util/messaging.rs @@ -201,27 +201,35 @@ pub async fn fetch_gift_wraps_for_shared_key( } /// Internal: wrap a Mostro `Message` via [`wrap_message`] and publish it. +/// +/// Follows the mostro-core 0.10 dual-key split: `identity_keys` sign the +/// seal (long-lived reputation binding), `trade_keys` author the rumor and +/// produce the inner tuple signature. Pass the same `Keys` for both to +/// opt into full-privacy mode. async fn publish_gift_wrap( client: &Client, - signer_keys: &Keys, + identity_keys: &Keys, + trade_keys: &Keys, receiver_pubkey: &PublicKey, message: &Message, opts: WrapOptions, ) -> Result<()> { - let event = wrap_message(message, signer_keys, *receiver_pubkey, opts) + let event = wrap_message(message, identity_keys, trade_keys, *receiver_pubkey, opts) .await .map_err(|e| anyhow::anyhow!("Failed to wrap message: {e}"))?; client.send_event(&event).await?; Ok(()) } -/// Send a plain-text DM wrapped as a NIP-59 Gift Wrap using `signer_keys`. +/// Send a plain-text DM wrapped as a NIP-59 Gift Wrap. /// -/// The wrap uses `signed = false` so the inner rumor carries `(Message, None)`, -/// matching the behavior of the deleted `send_gift_wrap_dm_internal` helper. +/// The wrap uses `signed = false` so the inner rumor carries `(Message, None)`. +/// `identity_keys` sign the seal and `trade_keys` author the rumor; admin +/// flows that do not rotate trade keys should pass the admin keys for both. pub async fn send_plain_text_dm( client: &Client, - signer_keys: &Keys, + identity_keys: &Keys, + trade_keys: &Keys, receiver_pubkey: &PublicKey, text: &str, ) -> Result<()> { @@ -237,7 +245,15 @@ pub async fn send_plain_text_dm( expiration: None, signed: false, }; - publish_gift_wrap(client, signer_keys, receiver_pubkey, &dm_message, opts).await + publish_gift_wrap( + client, + identity_keys, + trade_keys, + receiver_pubkey, + &dm_message, + opts, + ) + .await } pub async fn wait_for_dm( @@ -318,18 +334,28 @@ async fn create_private_dm_event( /// Send a Mostro protocol message to `receiver_pubkey`. /// -/// * `signer_keys` drives the whole NIP-59 pipeline: it authors the inner -/// rumor, signs the seal, and (when `signed` is true) produces the inner -/// tuple signature. Pass admin keys for admin flows and per-order trade -/// keys for user flows. -/// * `to_user` routes the message as a NIP-17 `PrivateDirectMessage` -/// (kind 14) instead of a gift wrap. -/// * Respects `POW` (mined on the outer wrap / DM) and `SECRET` (when true -/// the inner tuple is unsigned). Gift wraps go through -/// [`mostro_core::prelude::wrap_message`]. +/// mostro-core 0.10 splits the NIP-59 pipeline across two keys: +/// +/// * `identity_keys` sign the seal (kind 13). Long-lived per user — the +/// key the Mostro node uses to attach reputation. Admin flows pass the +/// admin keys here; identity-scoped requests (restore, last trade index) +/// pass the account's identity keys. +/// * `trade_keys` author the rumor (kind 1) and produce the inner tuple +/// signature when `signed = true`. Rotated per order for user flows, +/// equal to `identity_keys` for full-privacy mode and for flows that +/// don't bind to a specific trade (admin, restore, last trade index). +/// +/// For NIP-17 `PrivateDirectMessage` traffic (`to_user = true`), kind 14 +/// is signed directly by `trade_keys` — identity is irrelevant because +/// there is no seal. +/// +/// Respects the `POW` and `SECRET` env vars: PoW is mined on the outer +/// wrap (or kind-14 event), and `SECRET=true` flips the inner tuple to +/// unsigned. Gift wraps go through [`mostro_core::prelude::wrap_message`]. pub async fn send_dm( client: &Client, - signer_keys: &Keys, + identity_keys: &Keys, + trade_keys: &Keys, receiver_pubkey: &PublicKey, payload: String, expiration: Option, @@ -338,7 +364,7 @@ pub async fn send_dm( let pow = parse_pow_env()?; if to_user { - let event = create_private_dm_event(signer_keys, receiver_pubkey, payload, pow).await?; + let event = create_private_dm_event(trade_keys, receiver_pubkey, payload, pow).await?; client.send_event(&event).await?; return Ok(()); } @@ -352,7 +378,15 @@ pub async fn send_dm( signed: !private, }; - publish_gift_wrap(client, signer_keys, receiver_pubkey, &message, opts).await + publish_gift_wrap( + client, + identity_keys, + trade_keys, + receiver_pubkey, + &message, + opts, + ) + .await } pub async fn print_dm_events( @@ -422,12 +456,14 @@ mod tests { #[tokio::test] async fn send_dm_gift_wrap_roundtrips_via_unwrap_message() { + let identity_keys = Keys::generate(); let trade_keys = Keys::generate(); let mostro_keys = Keys::generate(); let message = sample_protocol_message(Some(42)); let event = wrap_message( &message, + &identity_keys, &trade_keys, mostro_keys.public_key(), WrapOptions::default(), @@ -443,6 +479,7 @@ mod tests { .expect("addressed to mostro_keys"); assert_eq!(unwrapped.sender, trade_keys.public_key()); + assert_eq!(unwrapped.identity, identity_keys.public_key()); assert_eq!( unwrapped.message.as_json().unwrap(), message.as_json().unwrap() @@ -453,13 +490,36 @@ mod tests { ); } + #[tokio::test] + async fn full_privacy_mode_identity_equals_trade() { + let trade_keys = Keys::generate(); + let mostro_keys = Keys::generate(); + let message = sample_protocol_message(Some(7)); + + let event = wrap_message( + &message, + &trade_keys, + &trade_keys, + mostro_keys.public_key(), + WrapOptions::default(), + ) + .await + .expect("wrap"); + + let unwrapped = unwrap_message(&event, &mostro_keys).await.unwrap().unwrap(); + assert_eq!(unwrapped.sender, trade_keys.public_key()); + assert_eq!(unwrapped.identity, unwrapped.sender); + } + #[tokio::test] async fn secret_env_semantics_drop_inner_signature() { + let identity_keys = Keys::generate(); let trade_keys = Keys::generate(); let mostro_keys = Keys::generate(); let event = wrap_message( &sample_protocol_message(Some(1)), + &identity_keys, &trade_keys, mostro_keys.public_key(), WrapOptions { @@ -476,12 +536,14 @@ mod tests { #[tokio::test] async fn wrap_message_respects_pow_option() { + let identity_keys = Keys::generate(); let trade_keys = Keys::generate(); let mostro_keys = Keys::generate(); let pow = 4; let event = wrap_message( &sample_protocol_message(None), + &identity_keys, &trade_keys, mostro_keys.public_key(), WrapOptions { @@ -497,12 +559,14 @@ mod tests { #[tokio::test] async fn wrong_keys_yield_none_on_unwrap() { + let identity_keys = Keys::generate(); let trade_keys = Keys::generate(); let mostro_keys = Keys::generate(); let stranger = Keys::generate(); let event = wrap_message( &sample_protocol_message(Some(1)), + &identity_keys, &trade_keys, mostro_keys.public_key(), WrapOptions::default(), diff --git a/src/util/storage.rs b/src/util/storage.rs index ed237ec..f733064 100644 --- a/src/util/storage.rs +++ b/src/util/storage.rs @@ -48,11 +48,13 @@ pub async fn run_simple_order_msg( pub async fn admin_send_dm(ctx: &Context, msg: String) -> Result<()> { // Get admin keys let admin_keys = get_admin_keys(ctx)?; - // Admin identity binds via the rumor author / inner tuple signature - // produced by `wrap_message`, so the admin keys are the sole signer. + // Admin identity binds via the seal signer and the rumor author. The + // admin role doesn't rotate per-trade keys, so the same `admin_keys` + // signs both layers (full-privacy-style wrap in mostro-core 0.10). send_dm( &ctx.client, admin_keys, + admin_keys, &ctx.mostro_pubkey, msg, None, diff --git a/tests/parser_dms.rs b/tests/parser_dms.rs index f144d1a..4e48e10 100644 --- a/tests/parser_dms.rs +++ b/tests/parser_dms.rs @@ -234,6 +234,7 @@ async fn parse_dm_with_time_filter() { // drift between how we publish DMs and how we decode them. #[tokio::test] async fn parse_dm_events_accepts_wrap_message_output() { + let sender_identity_keys = Keys::generate(); let sender_trade_keys = Keys::generate(); let receiver_keys = Keys::generate(); @@ -246,6 +247,7 @@ async fn parse_dm_events_accepts_wrap_message_output() { ); let wrapped = wrap_message( &inner, + &sender_identity_keys, &sender_trade_keys, receiver_keys.public_key(), WrapOptions::default(), @@ -271,13 +273,15 @@ async fn parse_dm_events_accepts_wrap_message_output() { // treated as protocol violations. #[tokio::test] async fn parse_dm_events_skips_events_for_other_keys() { - let sender = Keys::generate(); + let sender_identity = Keys::generate(); + let sender_trade = Keys::generate(); let intended_recipient = Keys::generate(); let eavesdropper = Keys::generate(); let wrapped = wrap_message( &Message::new_order(None, Some(1), Some(1), Action::NewOrder, None), - &sender, + &sender_identity, + &sender_trade, intended_recipient.public_key(), WrapOptions::default(), )