diff --git a/docs/CHAT_KIND14_ACCEPTANCE.md b/docs/CHAT_KIND14_ACCEPTANCE.md new file mode 100644 index 0000000..42fe082 --- /dev/null +++ b/docs/CHAT_KIND14_ACCEPTANCE.md @@ -0,0 +1,42 @@ +# Kind-14 P2P chat — acceptance (mostrix#102) + +Tracks Step 8 of the kind-14 migration. Canonical criteria come from +[mostrix#102](https://github.com/MostroP2P/mostrix/issues/102) and the +[protocol chat spec](https://mostro.network/protocol/chat.html). + +**Status:** automated proofs green on `main`; live two-client smoke remains a +manual checklist below (relay storage caveat in the protocol docs). + +## Criteria → evidence + +| #102 criterion | Automated proof | Manual | +|---|---|---| +| Two clients exchange kind-14 chat; published events do **not** put either trade pubkey as outer author or sole `#p` | `chat_wrap_unwrap_roundtrip_preserves_sender_and_content`, `acceptance_kind14_wire_hides_trade_pubkeys` in `src/util/chat_utils.rs` | Two Mostrix builds on a live trade; inspect outer events on `wss://relay.mostro.network` | +| Non-`pub(K_sign)` authors never decrypt; relay filter excludes them | `resolve_chat_target_ignores_kind14_from_unknown_author`, `live_chat_filters_kind14_uses_authors_not_p_tags` (`chat_listener.rs`); allow-list rejects in `user_solver_chat_roundtrip_*` / `unwrap_rejects_*` (`chat_utils.rs`) | Publish junk kind-14 to `#p=pub(K_conv)` with a random author — client must ignore | +| Flood at conversation address does not degrade order/DM/dispute UI | `rate_limiter_allows_burst_then_rejects`, `outer_lru_rejects_duplicates_and_evicts`, `try_emit_drops_when_queue_full_without_blocking` (`chat_security.rs`); listener uses per-`ChatKeyId` buckets + bounded update channel | Hold My Trades / Messages usable while a peer floods | +| Restart does not re-download unbounded history | `clamp_chat_since_cursor_caps_future_poison`, `track_clamps_future_since_cursor`; hydrate uses `since` + `limit(100)` + 7-day lookback (`fetch_chat_messages_for_shared_key`) | Quit mid-chat, relaunch — only recent history hydrates | +| Disclosing `K_conv` = read-only for solvers | `conversation_disclosure_is_k_conv_not_k_sign_secret`, `observer_k_conv_only_unwraps_kind14`, `observer_k_conv_cannot_author_kind14`, `observer_cannot_unwrap_legacy_giftwrap_with_k_conv` | Shift+K → Observer paste `K_conv` only → read history; no send path | +| Dispute admin↔party uses the same envelope | `user_solver_chat_roundtrip_accepts_only_conversation_parties`; `dispute_chat_allowed_signers_includes_admin_and_party` | Admin Disputes in Progress chat both ways | +| Protocol v2 Mostro DMs still work (author routing intact) | `filter_protocol_dm_v2_*` (`filters.rs`); chat live filter is `authors=[pub(K_sign)]` so node-authored kind-14 stays on the DM listener | Order create/take/pay/release on a v2 node | + +## Dual-read window + +While [`CHAT_ACCEPT_LEGACY_GIFTWRAP`](../src/util/chat_utils.rs) is `true`: + +- **Outbound** P2P / dispute chat: kind 14 only. +- **Inbound**: kind 14 + legacy GiftWrap (`#p` = ECDH pubkey). +- **Observer**: kind 14 + `K_conv` only (cannot unwrap GiftWrap). + +Flip the const to `false` after coordinated deprecation with mobile / other clients. + +## Manual smoke (optional for CI) + +1. Two clients complete a trade chat round-trip on kind 14. +2. Confirm outer `pubkey == pub(K_sign)` and `#p == [pub(K_conv)]` (no trade keys). +3. Observer with disclosed `K_conv` (and optional locator) reads history; cannot send. +4. Protocol DMs on v1 and v2 instances still function (see [DM_LISTENER_FLOW.md](DM_LISTENER_FLOW.md#manual-verification-protocol-v2)). + +## Related + +- Migration steps 1–7 shipped via kind-14 PRs (core bump, adapters, listener, security, dual-read, Observer, docs). +- Tracking issue: [#102](https://github.com/MostroP2P/mostrix/issues/102). diff --git a/docs/DM_LISTENER_FLOW.md b/docs/DM_LISTENER_FLOW.md index 02ff995..822caf4 100644 --- a/docs/DM_LISTENER_FLOW.md +++ b/docs/DM_LISTENER_FLOW.md @@ -349,6 +349,6 @@ Use this checklist when validating dual-transport behavior against live nodes: 1. **v1 node** (`protocol_version: "1"`) — create order, take, pay invoice, release; flows unchanged (GiftWrap filters). 2. **v2 node** (`protocol_version: "2"`) — same flows over kind-14 subscribe + `unwrap_incoming`. 3. **Mid-trade restart** — quit and relaunch Mostrix; startup `fetch_events` replay hydrates Messages tab state via the active transport filter. -4. **P2P order chat** — kind 14 outbound (`chat_utils.rs`); inbound still dual-reads legacy GiftWrap while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true. Unrelated to protocol v2 Mostro DM cutover. +4. **P2P order chat** — kind 14 outbound (`chat_utils.rs`); inbound still dual-reads legacy GiftWrap while `CHAT_ACCEPT_LEGACY_GIFTWRAP` is true. Unrelated to protocol v2 Mostro DM cutover. Full #102 matrix: [CHAT_KIND14_ACCEPTANCE.md](CHAT_KIND14_ACCEPTANCE.md). 5. **Transport flip** (rare) — refresh Mostro Info when `protocol_version` changes; listener respawns with new filter shape. diff --git a/docs/README.md b/docs/README.md index e09f7c6..8a36711 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ Index of architecture and feature guides for the Mostrix TUI client. The [root R - **Startup & Configuration**: [STARTUP_AND_CONFIG.md](STARTUP_AND_CONFIG.md) — Boot sequence, settings (`blossom_servers`), background tasks, DM router wiring, reconnect; main loop **drains save/send-attachment and operation-result channels before draw** (150 ms refresh) - **DM listener & router**: [DM_LISTENER_FLOW.md](DM_LISTENER_FLOW.md) — `listen_for_order_messages`; transport-aware subscribe (`filter_protocol_dm_from_mostro`) and event gate (`transport.event_kind()`); outbound `send_dm` uses `wrap_message_with`; inbound parse uses `unwrap_incoming` - **Message Flow & Protocol**: [MESSAGE_FLOW_AND_PROTOCOL.md](MESSAGE_FLOW_AND_PROTOCOL.md) — How Mostrix talks to Mostro over Nostr (orders, protocol DMs, restarts, cooperative cancel / `TradeClosed`); **protocol v2** dual transport (`protocol_version` → subscribe, `wrap_message_with`, `unwrap_incoming` — see [Protocol v2 (NIP-44)](#protocol-v2-nip-44--protocol-dms-complete)); **maker bond** (`send_new_order` → `PayBondInvoice` / `PaymentRequestRequired`, deferred `NewOrder` after payment); **My Trades user order chat** relay sync, own-message echo skip, attachment receive/save, **outbound send** (Ctrl+O picker, trade-key Blossom auth, mobile-compatible wire JSON, upload-then-send retry / **Ctrl+Shift+O**, `pending_order_attachment_sends`), **JSON transcript persistence** (Ctrl+S after restart) +- **Kind-14 P2P chat acceptance**: [CHAT_KIND14_ACCEPTANCE.md](CHAT_KIND14_ACCEPTANCE.md) — mostrix#102 criteria mapped to automated tests + optional live smoke (closes the gift-wrap apocalypse migration) - **PoW & outbound events**: [POW_AND_OUTBOUND_EVENTS.md](POW_AND_OUTBOUND_EVENTS.md) — Instance `pow` and optional `pow_first_contact` (kind 38385), [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs), [`send_dm`](../src/util/dm_utils/mod.rs) → [`wrap_message_with`](../src/util/mod.rs) (GiftWrap outer PoW or v2 signed kind-14) - **Database**: [DATABASE.md](DATABASE.md) — SQLite schema, `orders` / `users` / `admin_disputes`, migrations; **relay → SQLite reconcile** for terminal order statuses (`relay_order_db_reconcile.rs`) - **Key Management**: [KEY_MANAGEMENT.md](KEY_MANAGEMENT.md) — Deterministic derivation (NIP-06 path), identity vs trade keys @@ -44,7 +45,7 @@ Mostrix supports **dual-transport** Mostro **protocol DMs**. P2P order chat and | Status | What | |--------|------| -| **Done** | `mostro-core` **0.14.3** chat primitives (`K_conv` / `K_sign`); `protocol_version` on kind **38385**; [`transport_from_instance`](../src/util/mostro_info.rs); [`AppState.transport`](../src/ui/app_state.rs); Mostro Info tab; [`filter_protocol_dm_from_mostro`](../src/util/filters.rs); **await instance info** before listener (startup + [`dm_transport_for_mostro`](../src/ui/key_handler/async_tasks.rs) on reload/reconnect); **`send_dm` → `wrap_message_with`**; **`parse_dm_events` / listener → `unwrap_incoming`**; transport-aware subscribe + event gate; [`respawn_trade_dm_listener`](../src/ui/key_handler/async_tasks.rs) on manual info refresh when transport flips; v2 **first-contact PoW** (`pow_first_contact` / [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs)); P2P/dispute chat kind-14 send + dual-read + Observer `K_conv` disclosure (mostrix#102 steps 1–6) | +| **Done** | `mostro-core` **0.14.3** chat primitives (`K_conv` / `K_sign`); `protocol_version` on kind **38385**; [`transport_from_instance`](../src/util/mostro_info.rs); [`AppState.transport`](../src/ui/app_state.rs); Mostro Info tab; [`filter_protocol_dm_from_mostro`](../src/util/filters.rs); **await instance info** before listener (startup + [`dm_transport_for_mostro`](../src/ui/key_handler/async_tasks.rs) on reload/reconnect); **`send_dm` → `wrap_message_with`**; **`parse_dm_events` / listener → `unwrap_incoming`**; transport-aware subscribe + event gate; [`respawn_trade_dm_listener`](../src/ui/key_handler/async_tasks.rs) on manual info refresh when transport flips; v2 **first-contact PoW** (`pow_first_contact` / [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs)); P2P/dispute chat kind-14 send + dual-read + Observer `K_conv` disclosure + docs/acceptance (mostrix#102 steps 1–8; [CHAT_KIND14_ACCEPTANCE.md](CHAT_KIND14_ACCEPTANCE.md)) | **v2 end-to-end:** Mostrix auto-selects wire transport from instance info for both outbound and inbound protocol DMs. P2P / dispute chat is kind 14 outbound with a GiftWrap dual-read receive window (`CHAT_ACCEPT_LEGACY_GIFTWRAP`). Manual test checklist: [DM_LISTENER_FLOW.md — Manual verification](DM_LISTENER_FLOW.md#manual-verification-protocol-v2). diff --git a/src/util/chat_utils.rs b/src/util/chat_utils.rs index 2ebb5f7..e75a20e 100644 --- a/src/util/chat_utils.rs +++ b/src/util/chat_utils.rs @@ -1246,4 +1246,54 @@ mod tests { unwrap_observer_chat_event(&conv, Some(&sign.public_key()), &forged, &allowed) .expect_err("K_conv cannot produce a valid K_sign author"); } + + /// #102: published kind-14 chat must not expose either trade pubkey as outer + /// author or as the sole `#p` address — only `pub(K_sign)` / `pub(K_conv)`. + #[tokio::test] + async fn acceptance_kind14_wire_hides_trade_pubkeys() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let shared = SharedKey::derive(alice.secret_key(), &bob.public_key()).expect("shared"); + let (conv, sign) = shared.chat_keys().expect("chat keys"); + let wrapped = wrap_chat_message(&alice, &conv, &sign, "acceptance") + .await + .expect("wrap"); + + assert_eq!(wrapped.kind, Kind::PrivateDirectMessage); + assert_eq!(wrapped.pubkey, sign.public_key()); + assert_ne!(wrapped.pubkey, alice.public_key()); + assert_ne!(wrapped.pubkey, bob.public_key()); + assert_ne!(wrapped.pubkey, shared.public_key()); + + let p_tags: Vec = wrapped.tags.public_keys().collect(); + assert_eq!(p_tags, vec![conv.public_key()]); + assert!(!p_tags.contains(&alice.public_key())); + assert!(!p_tags.contains(&bob.public_key())); + assert!(!p_tags.contains(&shared.public_key())); + } + + /// #102: live subscribe shape is `authors = [pub(K_sign)]` (not trade keys). + #[test] + fn acceptance_chat_filter_authors_are_k_sign_only() { + let a = Keys::generate(); + let b = Keys::generate(); + let shared = SharedKey::derive(a.secret_key(), &b.public_key()).expect("shared"); + let (_conv, sign) = shared.chat_keys().expect("chat keys"); + let filter = chat_filter(sign.public_key()); + let json = serde_json::to_value(&filter).expect("filter json"); + let authors = json.get("authors").expect("authors"); + assert!(authors + .as_array() + .unwrap() + .iter() + .any(|v| v.as_str() == Some(&sign.public_key().to_hex()))); + assert!(json.get("#p").is_none()); + for trade in [a.public_key(), b.public_key()] { + assert!(authors + .as_array() + .unwrap() + .iter() + .all(|v| v.as_str() != Some(&trade.to_hex()))); + } + } }