Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,13 @@ bridged by flutter_rust_bridge.
## Transport (protocol v2)
- **Daemon messages** (new-order, take, release, cancel, dispute, rate, invoice, restore):
**NIP-44 / signed Kind 14** (transport v2), via `wrap_mostro_message`/`unwrap_mostro_message`.
- **Peer & dispute chat**: **NIP-59 gift wrap / Kind 1059**, via `wrap`/`unwrap`.
- Both live in `rust/src/nostr/gift_wrap.rs` (rename to `transport.rs` pending).
- **Peer chat**: **chat envelope** (kind 14 signed with `K_sign`, NIP-44 inner kind 1
signed by the trade key — <https://mostro.network/protocol/chat.html>), via
`mostro_wrap`/`mostro_unwrap` + `crypto/chat_keys.rs`. Outbound NIP-59 is gone from
this channel (gift-wrap flood attack, issue #246); inbound 1059 is still accepted
from pre-migration peers until the dual-read deadline (`LEGACY_CHAT_DEPRECATION_TS`).
- **Dispute admin chat**: still **NIP-59 gift wrap / Kind 1059**, via `wrap`/`unwrap`.
- All live in `rust/src/nostr/gift_wrap.rs` (rename to `transport.rs` pending).
- Wire status strings are **kebab-case** (`waiting-buyer-invoice`, `fiat-sent`).

## Translations
Expand Down Expand Up @@ -133,7 +138,7 @@ bridged by flutter_rust_bridge.
## Domain gotchas (durable)
- **Reputation/ratings come from Kind 38383 event tags, not a DB.** In-memory
`RATING_STORE`/`DISPUTE_STORE` are correct by design — don't invent "persist to DB" tasks.
The only real persistence gap is **chat history**.
Chat history persists to the `messages` table since #246 (web still memory-only, #233).
- **Order book is sourced only from daemon Kind 38383 events.** `create_order` waits for daemon
confirmation; on timeout it returns an error and **persists nothing** (no phantom order).

Expand Down
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ rand = { version = "0.8", features = ["getrandom"] }
# Hashing (nym derivation)
sha2 = "0.10"

# HKDF-SHA256 split of the chat ECDH secret into K_conv / K_sign
# (P2P chat envelope — https://mostro.network/protocol/chat.html, issue #246)
hkdf = "0.12"

# secp256k1 scalar math (ECDH shared-key derivation for P2P chat)
k256 = { version = "0.13", features = ["ecdh", "arithmetic"] }

Expand Down
1,493 changes: 1,235 additions & 258 deletions rust/src/api/messages.rs

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions rust/src/api/nostr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ pub async fn initialize(relays: Option<Vec<String>>) -> Result<()> {
let _ = flush_message_queue().await;
// Start (or re-start) Kind 38383 order book subscription.
crate::api::orders::subscribe_orders().await;
// Rebuild chat listeners for persisted active trades —
// sessions are in-memory, so after a restart nothing else
// would resubscribe. Idempotent: orders with a live chat
// task are skipped by the single-owner guard.
crate::api::messages::resubscribe_active_chats().await;
}
Ok(state) => {
log::info!("[nostr] connection state changed: {state:?}");
Expand Down
25 changes: 17 additions & 8 deletions rust/src/api/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1880,7 +1880,7 @@ async fn dispatch_mostro_message(
);
// Derive the ECDH shared key and store in session so the chat API
// can encrypt/decrypt P2P messages and subscribe to the right p-tag.
on_peer_pubkey_received(&order_id, trade_pubkey_hex, &peer_pubkey_hex).await;
on_peer_pubkey_received(&order_id, &peer_pubkey_hex).await;

// Sync the order status from the payload so the trade doesn't stay
// stuck at Pending in the DB and in-memory order book.
Expand Down Expand Up @@ -2195,7 +2195,7 @@ fn map_core_status(s: mostro_core::order::Status) -> Option<OrderStatus> {
/// stores it in the session, and spawns an incoming-chat subscription on the
/// shared-key pubkey so we receive peer messages from the moment the trade
/// goes active.
async fn on_peer_pubkey_received(order_id: &str, trade_pubkey_hex: &str, peer_pubkey_hex: &str) {
async fn on_peer_pubkey_received(order_id: &str, peer_pubkey_hex: &str) {
// Resolve trade key index from order_id.
let trade_index = match get_trade_key_index(order_id).await {
Some(idx) => idx,
Expand Down Expand Up @@ -2256,15 +2256,25 @@ async fn on_peer_pubkey_received(order_id: &str, trade_pubkey_hex: &str, peer_pu
"[orders] on_peer_pubkey_received: session not found for order={order_id}, skipping session update — incoming subscription still spawned"
);
}
// Spawn incoming-chat subscription on shared-key pubkey.
// Derive the chat conversation keys (K_conv / K_sign — HKDF split of the
// trade-key ECDH secret, protocol chat spec) and spawn the incoming-chat
// subscription pinned to their author key.
let (conv, sign) = match crate::crypto::chat_keys::derive_chat_keys(&trade_keys, &peer_pubkey)
{
Ok(pair) => pair,
Err(e) => {
log::error!("[orders] on_peer_pubkey_received: chat key derivation failed: {e}");
return;
}
};
let order_id_owned = order_id.to_string();
let trade_pubkey_hex_owned = trade_pubkey_hex.to_string();
crate::rt::spawn(async move {
crate::api::messages::subscribe_incoming_chat(
order_id_owned,
trade_pubkey_hex_owned,
shared_pubkey,
trade_keys,
peer_pubkey,
conv,
sign,
)
.await;
});
Expand Down Expand Up @@ -3505,8 +3515,7 @@ mod tests {
// Use a random order_id that has no session — should log a warning only.
on_peer_pubkey_received(
&uuid::Uuid::new_v4().to_string(),
"aabbccdd", // trade_pubkey_hex (irrelevant, no trade key stored)
"aabbccdd", // peer_pubkey_hex (also irrelevant)
"aabbccdd", // peer_pubkey_hex (irrelevant, no trade key stored)
)
.await;
// If we reach here without panicking the test passes.
Expand Down
121 changes: 121 additions & 0 deletions rust/src/crypto/chat_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/// Chat key derivation for the P2P chat envelope.
///
/// Implements the "Shared Key" section of the protocol chat spec
/// (<https://mostro.network/protocol/chat.html>): the ECDH secret shared by
/// the two trade keys is split with HKDF-SHA256 (empty salt, domain-separated
/// `info` strings) into two secp256k1 keypairs:
///
/// * `K_conv` — NIP-44 encryption of the payload; `pub(K_conv)` is the
/// conversation address carried in the `p` tag. Disclosed to a solver
/// during a dispute (read-only grant).
/// * `K_sign` — signs the outer kind 14 event; `pub(K_sign)` is the author
/// every client filters on. Never disclosed.
///
/// The ECDH here is `nostr::util::generate_shared_key` (the raw x-coordinate
/// of the shared point) — **not** `ecdh::derive_nip04_shared_key`, which
/// hashes it with SHA-256. The spec's test vector is derived from the raw
/// form; mixing the two silently yields a different conversation.
use anyhow::{anyhow, Result};
// Leading `::` selects the `hkdf` crate: `nostr_sdk::prelude` also exports a
// module by that name, so a plain `use hkdf::Hkdf` is ambiguous.
use ::hkdf::Hkdf;
use nostr_sdk::prelude::*;
use nostr_sdk::util::generate_shared_key;
use sha2::Sha256;

/// HKDF `info` strings. Changing either value changes the wire format.
const CONV_INFO: &[u8] = b"mostro:chat:conv:v1";
const SIGN_INFO: &[u8] = b"mostro:chat:sign:v1";

/// Derive the domain-separated conversation and signing keys for one order.
///
/// Both parties reach the same pair: the ECDH secret is symmetric, and HKDF
/// is deterministic.
///
/// Returns `(K_conv, K_sign)`.
pub fn derive_chat_keys(own_trade: &Keys, peer_trade: &PublicKey) -> Result<(Keys, Keys)> {
let shared = generate_shared_key(own_trade.secret_key(), peer_trade)
.map_err(|e| anyhow!("chat ECDH failed: {e}"))?;
let hkdf = Hkdf::<Sha256>::new(None, &shared);

let derive = |info: &[u8]| -> Result<Keys> {
// Retry with a counter byte on the negligible chance that the output
// is not a valid secp256k1 secret key (zero or >= curve order).
for counter in 0u16..=255 {
let mut labelled = info.to_vec();
if counter > 0 {
labelled.push(counter as u8);
}
let mut out = [0u8; 32];
hkdf.expand(&labelled, &mut out)
.map_err(|e| anyhow!("HKDF expand failed: {e}"))?;
if let Ok(sk) = SecretKey::from_slice(&out) {
return Ok(Keys::new(sk));
}
}
Err(anyhow!("HKDF failed to produce a valid secret key"))
};

Ok((derive(CONV_INFO)?, derive(SIGN_INFO)?))
}

#[cfg(test)]
mod tests {
use super::*;

// Trade keys from the spec's test vector.
const ALICE_SK: &str = "548f68890c49fa42f104c60352395e60ff030b0b407e955f1eed1400d6c0347a";
const BOB_SK: &str = "f258e73f07386d37133718b6127f873dd7c391b8f43b331ff8254034a13d2943";

#[test]
fn derivation_matches_the_spec_test_vector() {
let alice = Keys::parse(ALICE_SK).unwrap();
let bob = Keys::parse(BOB_SK).unwrap();

let (conv, sign) = derive_chat_keys(&alice, &bob.public_key()).unwrap();

assert_eq!(
conv.public_key().to_hex(),
"bceb1cd2a8e98ee9729122a1693edcc39c3ace04582ff96a26705c5e4078a6f2",
"pub(K_conv) diverges from the spec test vector",
);
assert_eq!(
sign.public_key().to_hex(),
"1dba04571059183f76b148119cfa6f8004dad30cb4e810180a6df17386a7f0b4",
"pub(K_sign) diverges from the spec test vector",
);
}

#[test]
fn both_parties_derive_the_same_pair() {
let alice = Keys::parse(ALICE_SK).unwrap();
let bob = Keys::parse(BOB_SK).unwrap();

let (a_conv, a_sign) = derive_chat_keys(&alice, &bob.public_key()).unwrap();
let (b_conv, b_sign) = derive_chat_keys(&bob, &alice.public_key()).unwrap();

assert_eq!(a_conv.public_key(), b_conv.public_key());
assert_eq!(a_sign.public_key(), b_sign.public_key());
}

#[test]
fn conv_and_sign_keys_differ() {
let alice = Keys::parse(ALICE_SK).unwrap();
let bob = Keys::parse(BOB_SK).unwrap();

let (conv, sign) = derive_chat_keys(&alice, &bob.public_key()).unwrap();
assert_ne!(conv.public_key(), sign.public_key());
}

#[test]
fn different_orders_yield_different_conversations() {
// Fresh trade keys — a different order derives unrelated chat keys.
let alice1 = Keys::generate();
let alice2 = Keys::generate();
let bob = Keys::generate();

let (conv1, _) = derive_chat_keys(&alice1, &bob.public_key()).unwrap();
let (conv2, _) = derive_chat_keys(&alice2, &bob.public_key()).unwrap();
assert_ne!(conv1.public_key(), conv2.public_key());
}
}
1 change: 1 addition & 0 deletions rust/src/crypto/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod chat_keys;
pub mod ecdh;
pub mod file_enc;
pub mod keys;
Expand Down
Loading
Loading