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
42 changes: 42 additions & 0 deletions docs/CHAT_KIND14_ACCEPTANCE.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 1 addition & 1 deletion docs/DM_LISTENER_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

50 changes: 50 additions & 0 deletions src/util/chat_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicKey> = 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())));
}
}
}