diff --git a/docs/architecture/DISPUTE_CHAT_KIND14.md b/docs/architecture/DISPUTE_CHAT_KIND14.md index 4e3bb868a..c29a8f2ed 100644 --- a/docs/architecture/DISPUTE_CHAT_KIND14.md +++ b/docs/architecture/DISPUTE_CHAT_KIND14.md @@ -73,7 +73,7 @@ The spec requires filtering by `authors`, **never by `#p`**: a `#p` filter would third party flood the subscription with junk events tagged to the conversation pubkey. The backlog is bounded by a **durable per-conversation `since` cursor** -(`DisputeChatCursorStore`, persisted in SharedPreferences), as the spec mandates: +(`ChatCursorStore`, persisted in SharedPreferences), as the spec mandates: - The cursor advances **only after `chatUnwrap` accepts an event**, clamped to `min(accepted_timestamp, local_now)` so a future-dated event within the skew tolerance @@ -123,7 +123,7 @@ event id (`eventStore.hasItem`) and UI state by inner event id. | Envelope (`chatWrap`/`chatUnwrap`) | `lib/data/models/nostr_event.dart` | | Dispute chat notifier (subscribe/send/receive/history) | `lib/features/disputes/notifiers/dispute_chat_notifier.dart` | | Centralized `disputeChat` filter (also reused by background) | `lib/features/subscriptions/subscription_manager.dart` | -| Durable `since` cursor (`DisputeChatCursorStore`) | `lib/services/dispute_chat_cursor_store.dart` | +| Durable `since` cursor (`ChatCursorStore`) | `lib/services/chat_cursor_store.dart` | | Background push routing (`author == pub(K_sign)`) | `lib/features/notifications/services/background_notification_service.dart` | | Derivation + envelope tests (incl. official test vector) | `test/shared/utils/chat_keys_test.dart`, `test/data/models/nostr_event_chat_test.dart` | @@ -136,9 +136,10 @@ event id (`eventStore.hasItem`) and UI state by inner event id. kind (14 → `chatUnwrap`, 1059 → `p2pUnwrap`), so existing history stays visible. - **Known accepted gap:** legacy 1059 chat events still sitting on relays but never received before the app update are not fetched after it. -- **P2P peer chat (buyer↔seller) is unchanged** and still uses the legacy 1-layer gift - wrap (`p2pWrap`/`p2pUnwrap`). Its migration to this same envelope is future work and - should reuse `ChatKeys` + `chatWrap`/`chatUnwrap` as-is. +- **P2P peer chat (buyer↔seller) uses this same envelope** (`ChatKeys` + + `chatWrap`/`chatUnwrap`), with the conversation keys derived from the peer shared key + instead of the admin one. See `P2P_CHAT_SYSTEM.md`. `p2pUnwrap` remains only for + pre-migration history stored on disk (both chats). - **Multimedia is unaffected.** Attachment encryption (ChaCha20-Poly1305) keys off the raw ECDH secret bytes (`NostrUtils.sharedKeyToBytes(adminSharedKey)`), not K_conv/K_sign, so Blossom attachments remain compatible in both directions. diff --git a/docs/architecture/NOSTR.md b/docs/architecture/NOSTR.md index f02737758..761e541ee 100755 --- a/docs/architecture/NOSTR.md +++ b/docs/architecture/NOSTR.md @@ -235,9 +235,11 @@ The app implements a three-layer encryption system for all private communication - **Market Discovery**: Browse available trades - **Order Metadata**: Trade parameters and requirements -#### P2P Chat Messages (Kind 1059) +#### P2P Chat Messages (Kind 14) - **Peer-to-Peer**: Direct communication between traders -- **Envelope**: Legacy 1-layer gift wrap (`p2pWrap`/`p2pUnwrap`) addressed to the ECDH shared pubkey +- **Envelope**: Kind-14 chat envelope signed by `K_sign`, NIP-44 encrypted under `K_conv` + (both HKDF-derived from the peer ECDH shared secret); legacy kind-1059 gift wraps are + read from local storage only - **Real-time**: Live chat during active trades #### Dispute Chat Messages (Kind 14) diff --git a/docs/architecture/P2P_CHAT_SYSTEM.md b/docs/architecture/P2P_CHAT_SYSTEM.md index a11a18564..1e56c35ef 100644 --- a/docs/architecture/P2P_CHAT_SYSTEM.md +++ b/docs/architecture/P2P_CHAT_SYSTEM.md @@ -2,12 +2,13 @@ This document describes how the peer-to-peer chat between trading parties works at the implementation level: how events flow from relays to the UI, how messages are persisted, what is stored encrypted vs. in plaintext, and known issues that have been fixed. -For the **protocol specification** (NIP-59, ECDH, event format), see the [Mostro P2P Chat protocol](https://mostro.network/protocol/chat.html) ([source](https://github.com/MostroP2P/protocol)). +For the **protocol specification** (ECDH, HKDF key derivation, event format), see the [Mostro P2P Chat protocol](https://mostro.network/protocol/chat.html) ([source](https://github.com/MostroP2P/protocol)). -> **Note:** P2P peer chat still uses the legacy 1-layer gift wrap (kind 1059) described -> below. The spec's newer kind-14 chat envelope is already used by the **dispute chat** -> (see `DISPUTE_CHAT_KIND14.md`); migrating P2P chat to it is pending and should reuse the -> same `ChatKeys` / `chatWrap` / `chatUnwrap` primitives. +> **Note:** P2P peer chat uses the spec's kind-14 chat envelope (`ChatKeys` + +> `chatWrap`/`chatUnwrap`), the same primitives as the **dispute chat** (see +> `DISPUTE_CHAT_KIND14.md`). The legacy 1-layer gift wrap (kind 1059) was fully +> replaced on the wire; `p2pUnwrap` remains only to read pre-migration history +> stored on disk. --- @@ -19,9 +20,11 @@ For the **protocol specification** (NIP-59, ECDH, event format), see the [Mostro | `ChatRoomNotifier` | `lib/features/chat/notifiers/chat_room_notifier.dart` | Per-order chat: receives events, stores to disk, decrypts, manages state | | `ChatRoomsNotifier` | `lib/features/chat/notifiers/chat_rooms_notifier.dart` | Chat list: loads, refreshes, reloads all chats | | `chatRoomsProvider` | `lib/features/chat/chat_room_provider.dart` | Riverpod family provider, creates and initializes `ChatRoomNotifier` | -| `EventStorage` | `lib/data/repositories/event_storage.dart` | Sembast store for gift wrap events | +| `EventStorage` | `lib/data/repositories/event_storage.dart` | Sembast store for encrypted chat envelopes | | `Session` | `lib/data/models/session.dart` | Holds trade keys, peer info, computes shared key via ECDH | -| `NostrEvent` extensions | `lib/data/models/nostr_event.dart` | `p2pWrap()` / `p2pUnwrap()` — encrypt/decrypt gift wraps | +| `ChatKeys` | `lib/shared/utils/chat_keys.dart` | HKDF derivation of K_conv / K_sign from the ECDH secret | +| `NostrEvent` extensions | `lib/data/models/nostr_event.dart` | `chatWrap()` / `chatUnwrap()` envelope; `p2pUnwrap()` for legacy stored history | +| `ChatCursorStore` | `lib/services/chat_cursor_store.dart` | Durable per-conversation `since` cursor (prefix `chat_since_`) | --- @@ -29,41 +32,43 @@ For the **protocol specification** (NIP-59, ECDH, event format), see the [Mostro ```text Relay - │ kind 1059 gift wrap events (encrypted) + │ kind 14 chat envelopes (NIP-44 encrypted, authored by K_sign) ▼ NostrService (WebSocket) │ ▼ SubscriptionManager - │ ONE subscription with ALL sharedKey pubkeys in a single NostrFilter + │ ONE subscription with ALL conversations' pub(K_sign) in a single NostrFilter │ Events dispatched via StreamController.broadcast() ▼ ChatRoomNotifier._onChatEvent() (one listener per active chat) │ - ├─ 1. Check p-tag matches this chat's sharedKey.public → skip if not ours + ├─ 1. Check author matches this chat's pub(K_sign) → skip if not ours ├─ 2. Dedup: eventStore.hasItem(event.id) → skip if already stored - ├─ 3. Store encrypted gift wrap to Sembast (kind 1059, NIP-44 encrypted content) - ├─ 4. Decrypt: event.p2pUnwrap(sharedKey) → plaintext kind 1 event - ├─ 5. Add to state.messages (in-memory only) - └─ 6. Notify chat list to refresh + ├─ 3. Store encrypted envelope to Sembast (kind 14, NIP-44 encrypted content) + ├─ 4. chatUnwrap(chatKeys, peerChatAllowedSigners) → verified kind 1 inner event + ├─ 5. Advance the persisted since cursor (only after acceptance) + ├─ 6. Add to state.messages (in-memory only) + └─ 7. Notify chat list to refresh ``` ### Key detail: single subscription, multiple listeners -`SubscriptionManager` creates **one** relay subscription containing all active chat shared key pubkeys: +`SubscriptionManager` creates **one** relay subscription containing the K_sign authors of all active chats. The spec requires filtering by `authors`, never by `#p`: a `#p` filter would let any third party flood the subscription with junk events, since relays only verify that an event is signed by *its own* author. ```dart // subscription_manager.dart — _createFilterForType() NostrFilter( - kinds: [1059], - p: sessions - .where((s) => s.sharedKey?.public != null) - .map((s) => s.sharedKey!.public) - .toList(), // ALL shared keys in ONE filter + kinds: [14], + authors: chatSessions + .map((s) => ChatKeys.fromSharedKey(s.sharedKey!).sign.public) + .toList(), // ALL conversations in ONE filter + since: chatSince, // earliest persisted cursor (ChatCursorStore) + limit: NostrEventExtensions.chatDefaultLimit, ); ``` -The relay sends events for all chats through this single subscription. Events are dispatched via a `StreamController.broadcast()` to all `ChatRoomNotifier` instances. Each notifier checks the event's `p` tag to determine if the event belongs to its chat. +The relay sends events for all chats through this single subscription. Events are dispatched via a `StreamController.broadcast()` to all `ChatRoomNotifier` instances. Each notifier checks the event's author against its own `pub(K_sign)` to determine if the event belongs to its chat. --- @@ -75,14 +80,16 @@ User types message ▼ ChatRoomNotifier.sendMessage(text) │ - ├─ 1. Create kind 1 inner event, signed with tradeKey - ├─ 2. p2pWrap(tradeKey, sharedKey.public) → kind 1059 gift wrap - │ - Generates ephemeral key pair (single-use) - │ - Encrypts inner event JSON with NIP-44 (ephemeral private + shared pubkey) - │ - p-tag = sharedKey.public - │ - Timestamp randomized to prevent time analysis + ├─ 1. createChatRumor: kind 1 inner event signed with tradeKey, + │ carrying a random `u` nonce tag (same-second identical texts + │ still get distinct inner ids) + ├─ 2. chatWrap(chatKeys) → kind 14 envelope + │ - NIP-44 self-encryption under K_conv + │ - Authored and signed by K_sign + │ - Exactly one p-tag = pub(K_conv) + │ - Outer timestamp equals the inner one (spec replay defense) ├─ 3. Publish wrapped event to relay - ├─ 4. Persist wrapped event to Sembast (encrypted, kind 1059) + ├─ 4. Persist wrapped event to Sembast (encrypted, kind 14) ├─ 5. Add inner event (plaintext) to state.messages for immediate UI display └─ 6. Notify chat list to refresh ``` @@ -93,26 +100,26 @@ Step 4 ensures sent messages survive app restarts even if the relay echo never a ## Storage: What Is on Disk -Events are stored in Sembast's `events` store as encrypted gift wraps: +Events are stored in Sembast's `events` store as encrypted kind-14 envelopes (pre-migration history remains as kind-1059 gift wraps): ```dart { 'id': event.id, // event hash 'created_at': , - 'kind': 1059, // gift wrap - 'content': '', // ciphertext — NOT readable without private key - 'pubkey': '', // single-use key, does not identify the sender - 'sig': '', - 'tags': [['p', '']], + 'kind': 14, // chat envelope (legacy history: 1059) + 'content': '', // ciphertext — NOT readable without K_conv + 'pubkey': '', // conversation signing key, stable per trade + 'sig': '', + 'tags': [['p', '']], 'type': 'chat', // app metadata 'order_id': '', // app metadata — links event to a specific trade } ``` **Privacy properties:** -- The `content` field is NIP-44 encrypted. Reading it requires the ECDH shared key's private component. -- The `pubkey` is an ephemeral key generated per message. It does not identify the sender. -- The `p` tag contains the shared key's public component, not any party's real identity. +- The `content` field is NIP-44 encrypted under K_conv. Reading it requires the ECDH shared secret (or the derived K_conv). +- Neither `pubkey` (K_sign) nor the `p` tag (K_conv) is linkable to any party's trade or identity keys without the ECDH secret. +- Sender identity (trade pubkey) is inside the encrypted payload, authenticated by the inner signature. - The `order_id` is app-internal metadata not present in the Nostr event itself. **What is NOT on disk:** @@ -127,7 +134,7 @@ Events are stored in Sembast's `events` store as encrypted gift wraps: `state.messages` holds decrypted `NostrEvent` objects (kind 1) in RAM: ```dart -// After p2pUnwrap: +// After chatUnwrap: NostrEvent( kind: 1, content: "Let's reestablish the peer-to-peer nature of Bitcoin!", // plaintext @@ -136,7 +143,7 @@ NostrEvent( ) ``` -These exist **only in memory**. When the app closes, they are lost. On restart, `_loadHistoricalMessages()` reads the encrypted gift wraps from Sembast and decrypts them again. +These exist **only in memory**. When the app closes, they are lost. On restart, `_loadHistoricalMessages()` reads the encrypted envelopes from Sembast and decrypts them again. --- @@ -210,15 +217,16 @@ Sembast query: WHERE type = 'chat' AND order_id = orderId ▼ For each stored event: ├─ Reconstruct NostrEvent from stored map - ├─ Verify p-tag matches session.sharedKey.public - ├─ p2pUnwrap(sharedKey) → decrypt to kind 1 inner event + ├─ kind 14 → chatUnwrap(chatKeys, peerChatAllowedSigners) + ├─ kind 1059 (legacy, pre-migration) → verify p-tag matches + │ sharedKey.public, then p2pUnwrap(sharedKey) └─ Add to historicalMessages list │ ▼ Merge with existing state.messages, deduplicate by ID, sort by created_at ``` -The p-tag check during loading (line 353) acts as a safety filter: even if an event was somehow stored with an incorrect `order_id`, it won't be displayed in the wrong chat because the decryption key wouldn't match. +Decryption itself acts as a safety filter: even if an event were somehow stored with an incorrect `order_id`, it won't be displayed in the wrong chat because the conversation keys wouldn't match. --- @@ -230,15 +238,15 @@ Text messages have plain string content. Multimedia messages use JSON content: 1. File/image encrypted with ChaCha20-Poly1305 using shared key bytes 2. Uploaded to Blossom server (encrypted blob) 3. JSON metadata sent as message content: `{ "type": "image_encrypted", "blossomUrl": "...", ... }` -4. The JSON is inside the NIP-44 gift wrap — doubly encrypted +4. The JSON is inside the NIP-44 chat envelope — doubly encrypted ### Receiving -1. Gift wrap arrives → decrypted to kind 1 → JSON content detected +1. Envelope arrives → decrypted to kind 1 → JSON content detected 2. `_processMessageContent()` identifies `image_encrypted` / `file_encrypted` 3. Downloads encrypted blob from Blossom, decrypts with shared key 4. Caches decrypted media in memory (`MediaCacheMixin`) -**Disk**: Only the gift wrap is stored (Blossom URL inside encrypted payload). +**Disk**: Only the encrypted envelope is stored (Blossom URL inside encrypted payload). Attachment encryption keys off the raw ECDH secret bytes, not K_conv/K_sign, so attachments are wire-compatible with other clients. **Memory**: Decrypted media cached for display, cleared on dispose. --- @@ -305,10 +313,12 @@ With 2+ active trades, counterpart messages disappear after closing and reopenin | `lib/features/chat/chat_room_provider.dart` | Provider creation, async initialization | | `lib/shared/providers/app_init_provider.dart` | App startup sequence, chat subscription setup | | `lib/data/repositories/event_storage.dart` | Sembast wrapper for event persistence | -| `lib/data/models/session.dart` | Session model, ECDH shared key computation | -| `lib/data/models/nostr_event.dart` | p2pWrap / p2pUnwrap encryption/decryption | +| `lib/data/models/session.dart` | Session model, ECDH shared key computation, peerChatAllowedSigners | +| `lib/data/models/nostr_event.dart` | createChatRumor / chatWrap / chatUnwrap; p2pUnwrap for legacy history | +| `lib/shared/utils/chat_keys.dart` | HKDF derivation of K_conv / K_sign | +| `lib/services/chat_cursor_store.dart` | Durable per-conversation since cursor | | `lib/services/lifecycle_manager.dart` | Foreground/background transitions, chat reload | --- -*Last Updated: March 2026* +*Last Updated: August 2026* diff --git a/lib/background/background.dart b/lib/background/background.dart index 4929b6217..cccfe2400 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -7,10 +7,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/notifications/services/background_notification_service.dart' as notification_service; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/services/logger_service.dart' as logger_service; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -38,11 +40,29 @@ String currentLanguage = 'en'; /// otherwise DMs that arrive while the app stays in background would /// never trigger a notification until the user reopens the app. /// +/// `orderId` identifies the conversation so the subscription can reuse its +/// persisted `since` cursor; a null id falls back to a short lookback. +/// /// The callback is set inside `serviceMain` (background isolate) so it /// has access to the local `activeSubscriptions` map, `nostrService`, /// `eventStore`, and the `logger`. It is `null` in the foreground /// isolate and any call there is a no-op. -void Function(String sharedKeyPublic)? addChatSubscriptionFromBackground; +void Function(String signPubkey, String? orderId)? + addChatSubscriptionFromBackground; + +/// Callback set by `serviceMain` so `background_notification_service` can +/// persist an authenticated chat envelope through the isolate's already-open +/// event store, instead of opening a second handle on the same database. +/// +/// Without this durable marker the background notifies but stores nothing, so +/// a service restart restores the same filter, refetches the event and +/// notifies again — and the foreground can only recover the message by +/// refetching it from a relay. Only accepted (chatUnwrap-verified) events are +/// passed here; rejected ones must leave storage untouched. +/// +/// It is `null` in the foreground isolate and any call there is a no-op. +Future Function(String id, Map record)? + persistChatEventFromBackground; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { @@ -152,29 +172,55 @@ Future serviceMain(ServiceInstance service) async { logger?.e('Failed to restore background filters: $e'); } + // Expose a hook so accepted chat envelopes get a durable marker, + // reusing the event store this isolate already opened. + persistChatEventFromBackground = + (String id, Map record) async { + try { + await eventStore?.putItem(id, record); + } catch (e) { + logger?.e('Failed to persist chat event in background: $e'); + } + }; + // Expose a hook that `background_notification_service` can call after // it has persisted a peer update to add a live chat subscription // without waiting for the foreground app to come back. - addChatSubscriptionFromBackground = (String sharedKeyPublic) async { + addChatSubscriptionFromBackground = + (String signPubkey, String? orderId) async { try { - // Avoid creating duplicate subscriptions for the same shared key. + // Avoid creating duplicate subscriptions for the same conversation + // (identified by its K_sign author). final alreadySubscribed = activeSubscriptions.values.any((entry) { final filters = entry['filters']; if (filters is! List) return false; return filters.any((f) { if (f is! Map) return false; - final p = f['#p']; - return p is List && p.contains(sharedKeyPublic); + final authors = f['authors']; + return authors is List && authors.contains(signPubkey); }); }); if (alreadySubscribed) { - logger?.d('Chat sub for $sharedKeyPublic already active'); + logger?.d('Chat sub for $signPubkey already active'); return; } - final filter = NostrFilter( - kinds: [1059], - p: [sharedKeyPublic], + // Same cursor-backed filter the foreground builds, so a restored + // subscription replays a bounded backlog instead of everything. + // The cursor is only read here: the background never persists + // events, so advancing it would move `since` past a message that + // was never stored anywhere. + final cursorStore = ChatCursorStore( + SharedPreferencesAsync(), + keyPrefix: ChatCursorStore.peerKeyPrefix, + ); + final since = + (orderId != null ? await cursorStore.sinceFor(orderId) : null) ?? + DateTime.now().subtract(ChatCursorStore.cursorOverlap); + + final filter = NostrEventExtensions.chatSubscriptionFilter( + signPubkeys: [signPubkey], + since: since, ); final request = NostrRequest(filters: [filter]); final subscription = nostrService.subscribeToEvents(request); @@ -216,7 +262,7 @@ Future serviceMain(ServiceInstance service) async { logger?.e('Failed to persist updated chat filter: $e'); } - logger?.i('Added background chat subscription for $sharedKeyPublic'); + logger?.i('Added background chat subscription for $signPubkey'); } catch (e, stackTrace) { logger?.e( 'Failed to add background chat subscription', diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 6dba4b366..743fdecb4 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/range_amount.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; @@ -255,58 +256,9 @@ extension NostrEventExtensions on NostrEvent { return now.subtract(Duration(seconds: randomSeconds)); } - /// P2P Chat: Simplified NIP-59 wrapper for peer-to-peer chat - /// Wraps a signed kind 1 event directly in a kind 1059 wrapper - /// This is different from mostroWrap which uses a SEAL intermediate layer - /// - /// According to Mostro documentation: - /// 1. Inner event is kind 1, signed by sender - /// 2. Wrapper is kind 1059, encrypted with ephemeral key - /// 3. No SEAL (kind 13) intermediate layer - Future p2pWrap(NostrKeyPairs senderKeys, String receiverPubkey) async { - if (kind != 1) { - throw ArgumentError('Expected kind 1 event for P2P chat, got: $kind'); - } - - if (content == null || content!.isEmpty) { - throw ArgumentError('Message content is empty'); - } - - try { - // The inner event must be signed by the sender - // This is already done when creating the event with fromPartialData - final innerEventJson = jsonEncode(toMap()); - - // Generate ephemeral key pair (single-use for this message) - final ephemeralKeyPair = NostrUtils.generateKeyPair(); - - // Encrypt the inner event with ephemeral key + receiver's public key - final encryptedContent = await NostrUtils.encryptNIP44( - innerEventJson, - ephemeralKeyPair.private, - receiverPubkey, - ); - - // Create wrapper (kind 1059) with randomized timestamp - final wrapper = NostrEvent.fromPartialData( - kind: 1059, - content: encryptedContent, - keyPairs: ephemeralKeyPair, - tags: [ - ["p", receiverPubkey], // Identifies the receiver (shared key pubkey) - ], - createdAt: _randomizedTimestamp(), - ); - - return wrapper; - } catch (e) { - throw Exception('Failed to wrap P2P chat message: $e'); - } - } - - /// P2P Chat: Unwrap a simplified NIP-59 wrapper for peer-to-peer chat - /// Decrypts a kind 1059 wrapper to extract the signed kind 1 inner event - /// This is different from mostroUnWrap which expects a SEAL intermediate layer + /// Legacy chat: unwrap the pre-migration 1-layer gift wrap (kind 1059). + /// Kept only to read chat history stored on disk before the kind-14 + /// envelope (chatWrap/chatUnwrap) replaced this format on the wire. Future p2pUnwrap(NostrKeyPairs receiver) async { if (kind != 1059) { throw ArgumentError('Expected kind 1059 (Gift Wrap), got: $kind'); @@ -365,9 +317,80 @@ extension NostrEventExtensions on NostrEvent { /// Mostro chat envelope (kind 14): default subscription event limit. static const chatDefaultLimit = 100; + /// Mostro chat: on-disk record for a peer chat envelope, keyed by order. + /// Shared by the foreground notifier and the background isolate so both + /// write the shape `_loadHistoricalMessages` expects. + Map peerChatRecord(String orderId) => { + ..._chatRecordFields(), + 'type': 'chat', + 'order_id': orderId, + }; + + /// Mostro chat: on-disk record for a dispute chat envelope, keyed by dispute. + Map disputeChatRecord(String disputeId) => { + ..._chatRecordFields(), + 'type': 'dispute_chat', + 'dispute_id': disputeId, + }; + + Map _chatRecordFields() => { + 'id': id, + 'created_at': createdAt!.millisecondsSinceEpoch ~/ 1000, + 'kind': kind, + 'content': content, + 'pubkey': pubkey, + 'sig': sig, + 'tags': tags, + }; + + /// Mostro chat: subscription filter for one or more conversations, matched + /// by their K_sign authors (never by `#p`, which a third party could flood). + /// Shared by the foreground subscriptions and the background isolate so the + /// two cannot drift into different backlog bounds. + static NostrFilter chatSubscriptionFilter({ + required List signPubkeys, + required DateTime since, + }) { + return NostrFilter( + kinds: [14], + authors: signPubkeys, + since: since, + limit: chatDefaultLimit, + ); + } + + /// Mostro chat: build the signed kind 1 inner event for a chat message. + /// A random `u` nonce tag keeps same-second identical texts from + /// colliding into one inner id, which dedup would drop as a replay. + static NostrEvent createChatRumor({ + required NostrKeyPairs senderKeys, + required String content, + DateTime? createdAt, + }) { + return NostrEvent.fromPartialData( + keyPairs: senderKeys, + content: content, + kind: 1, + tags: [ + ["u", _chatNonceHex()], + ], + createdAt: createdAt, + ); + } + + /// 8 random bytes as 16 hex chars, from a cryptographic source. + static String _chatNonceHex() { + final rng = Random.secure(); + return List.generate( + 8, + (_) => rng.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + } + /// Mostro chat: wrap this signed kind 1 event into a kind 14 envelope /// signed by `K_sign`, NIP-44 self-encrypted under `K_conv`. - /// Supersedes the gift wrap (p2pWrap) for dispute and peer chat. + /// Supersedes the legacy 1-layer gift wrap (kind 1059) for dispute and + /// peer chat; p2pUnwrap remains only to read stored pre-migration history. /// /// The outer event shares this event's timestamp (recipients reject a /// mismatch) and carries exactly one `p` tag = pub(K_conv). diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 642c19094..2534541cf 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -211,6 +211,13 @@ class Session { if (_adminPubkey != null) _adminPubkey!, ]; + /// Inner event signers accepted in the peer chat conversation. + /// The peer is always set when sharedKey is (see the peer setter). + List get peerChatAllowedSigners => [ + tradeKey.public, + if (_peer != null) _peer!.publicKey, + ]; + /// Compute and store the admin shared key via ECDH void setAdminPeer(String adminPubkey) { if (adminPubkey.isEmpty || adminPubkey.length != 64) { diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index fc0a3f6a4..43c38d382 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -1,13 +1,14 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; import 'package:sembast/sembast.dart'; @@ -22,6 +23,7 @@ import 'package:mostro_mobile/shared/providers/push_notification_service_provide import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; import 'package:mostro_mobile/features/chat/utils/message_type_helpers.dart'; import 'package:mostro_mobile/shared/mixins/media_cache_mixin.dart'; +import 'package:mostro_mobile/shared/utils/chat_keys.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { @@ -50,12 +52,25 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { @override bool get mounted => super.mounted; + ChatKeys? _chatKeys; + String? _chatKeysSource; + ChatRoomNotifier( super.state, this.orderId, this.ref, ); + /// Derive (and cache) the K_conv/K_sign pair from the peer shared key. + ChatKeys _getChatKeys(Session session) { + final shared = session.sharedKey!; + if (_chatKeys == null || _chatKeysSource != shared.public) { + _chatKeys = ChatKeys.fromSharedKey(shared); + _chatKeysSource = shared.public; + } + return _chatKeys!; + } + /// Initialize the chat room by loading historical messages and subscribing to new events Future initialize() async { await _loadHistoricalMessages(); @@ -120,10 +135,13 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { ); } - void _onChatEvent(NostrEvent event) async { + /// Test hook for the private stream handler. + @visibleForTesting + Future handleChatEvent(NostrEvent event) => _onChatEvent(event); + + Future _onChatEvent(NostrEvent event) async { try { - if (event.kind != 1059) { - logger.w('Ignoring non-chat event kind: ${event.kind}'); + if (event.kind != 14) { return; } @@ -131,47 +149,42 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { // events for ALL chats to every ChatRoomNotifier. Without this early // check, multiple notifiers race to store the same event with their own // orderId, causing messages to be stored under the wrong order and - // disappear after app restart. + // disappear after app restart. Ownership is the outer author: the + // K_sign key derived from this conversation's shared key. final session = ref.read(sessionProvider(orderId)); if (session == null || session.sharedKey == null) { return; } - final pTag = event.tags?.firstWhere( - (tag) => tag.isNotEmpty && tag[0] == 'p', - orElse: () => [], - ) ?? - []; - - if (pTag.isEmpty || - pTag.length < 2 || - pTag[1] != session.sharedKey!.public) { + final chatKeys = _getChatKeys(session); + if (event.pubkey != chatKeys.sign.public) { return; } - // Event belongs to this chat — now check for duplicates and store + // Already on disk means a relay re-delivery, an own echo, or an event + // the background service stored while the app slept. Keep processing: + // state is keyed by inner id, so only the write is redundant. final eventStore = ref.read(eventStorageProvider); - if (await eventStore.hasItem(event.id!)) { - return; + final alreadyStored = await eventStore.hasItem(event.id!); + + // Unwrap and authenticate BEFORE persisting: the signature is not part + // of the event id, so storing an unverified copy would let a corrupted + // duplicate occupy the id and dedup away the valid one for good + final chat = await event.chatUnwrap( + chatKeys, + session.peerChatAllowedSigners, + ); + + if (!alreadyStored) { + await eventStore.putItem(event.id!, event.peerChatRecord(orderId)); } - await eventStore.putItem( - event.id!, - { - 'id': event.id, - 'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000, - 'kind': event.kind, - 'content': event.content, - 'pubkey': event.pubkey, - 'sig': event.sig, - 'tags': event.tags, - 'type': 'chat', - 'order_id': orderId, - }, + // Advance the persisted since cursor only after the event is accepted + // (clamped to the local clock inside the store) + unawaited( + ref.read(chatCursorStoreProvider).advance(orderId, event.createdAt!), ); - final chat = await event.p2pUnwrap(session.sharedKey!); - // Check if message already exists to prevent duplicates final messageExists = state.messages.any((m) => m.id == chat.id); if (!messageExists) { @@ -212,20 +225,16 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { return; } - final innerEvent = NostrEvent.fromPartialData( - keyPairs: session.tradeKey, + // Inner event (kind 1 with a `u` nonce tag) signed by the trade key; + // its real id drives optimistic UI and relay echo deduplication + final innerEvent = NostrEventExtensions.createChatRumor( + senderKeys: session.tradeKey, content: text, - kind: 1, - tags: [ - ["p", session.sharedKey!.public], - ], ); try { - final wrappedEvent = await innerEvent.p2pWrap( - session.tradeKey, - session.sharedKey!.public, - ); + // Wrap into the kind-14 envelope (signed by K_sign, encrypted with K_conv) + final wrappedEvent = await innerEvent.chatWrap(_getChatKeys(session)); // Publish to network - await to catch network/initialization errors try { @@ -237,9 +246,9 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { } // Wake the peer's device via the push server (fire-and-forget). - // Required for P2P chat because the server's Nostr listener only matches - // kind 1059 events on the recipient's tradeKey.public, and P2P chat - // events use sharedKey.public. + // Required for P2P chat because the server's Nostr listener matches + // events addressed to the recipient's tradeKey.public, and the chat + // envelope is authored by K_sign and tagged to pub(K_conv) instead. final peerPubkey = session.peer?.publicKey; if (peerPubkey != null) { unawaited( @@ -254,18 +263,7 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { final eventStore = ref.read(eventStorageProvider); await eventStore.putItem( wrappedEvent.id!, - { - 'id': wrappedEvent.id, - 'created_at': - wrappedEvent.createdAt!.millisecondsSinceEpoch ~/ 1000, - 'kind': wrappedEvent.kind, - 'content': wrappedEvent.content, - 'pubkey': wrappedEvent.pubkey, - 'sig': wrappedEvent.sig, - 'tags': wrappedEvent.tags, - 'type': 'chat', - 'order_id': orderId, - }, + wrappedEvent.peerChatRecord(orderId), ); logger.d('Wrapped event persisted to storage for orderId: $orderId'); } catch (storageError) { @@ -391,22 +389,23 @@ class ChatRoomNotifier extends StateNotifier with MediaCacheMixin { 'tags': eventData['tags'], }); - logger.i( - 'Reconstructed event: ${storedEvent.id}, recipient: ${storedEvent.recipient}'); - - // Check if this event belongs to our chat (shared key) - if (session.sharedKey?.public == storedEvent.recipient) { - logger.i('Event belongs to our chat, unwrapping...'); - // Decrypt and unwrap the message - final unwrappedMessage = - await storedEvent.p2pUnwrap(session.sharedKey!); - historicalMessages.add(unwrappedMessage); - logger.i( - 'Successfully unwrapped message: ${unwrappedMessage.content}'); + // Decrypt and unwrap: kind 14 envelope, or legacy gift wrap + // stored before the kind-14 migration + final NostrEvent unwrappedMessage; + if (storedEvent.kind == 14) { + unwrappedMessage = await storedEvent.chatUnwrap( + _getChatKeys(session), + session.peerChatAllowedSigners, + ); } else { - logger.i( - 'Event does not belong to our chat. Expected: ${session.sharedKey?.public}, Got: ${storedEvent.recipient}'); + if (session.sharedKey?.public != storedEvent.recipient) { + logger.i( + 'Legacy event does not belong to our chat. Expected: ${session.sharedKey?.public}, Got: ${storedEvent.recipient}'); + continue; + } + unwrappedMessage = await storedEvent.p2pUnwrap(session.sharedKey!); } + historicalMessages.add(unwrappedMessage); } catch (e) { logger .e('Failed to process historical event ${eventData['id']}: $e'); diff --git a/lib/features/chat/widgets/user_information_tab.dart b/lib/features/chat/widgets/user_information_tab.dart index de92b51e0..72a25366b 100644 --- a/lib/features/chat/widgets/user_information_tab.dart +++ b/lib/features/chat/widgets/user_information_tab.dart @@ -5,6 +5,7 @@ import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; import 'package:mostro_mobile/shared/providers/legible_handle_provider.dart'; +import 'package:mostro_mobile/shared/utils/chat_keys.dart'; import 'package:mostro_mobile/shared/widgets/clickable_text_widget.dart'; class UserInformationTab extends ConsumerWidget { @@ -21,7 +22,10 @@ class UserInformationTab extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final handle = ref.read(nickNameProvider(peerPubkey)); final you = ref.read(nickNameProvider(session.tradeKey.public)); - final sharedKey = session.sharedKey?.private; + // Disclose K_conv, never the raw ECDH secret: that one also derives + // K_sign, which would let a reader forge messages in this conversation + final sharedKey = session.sharedKey; + final chatKeys = sharedKey != null ? ChatKeys.fromSharedKey(sharedKey) : null; return Container( color: AppTheme.backgroundDark, @@ -123,7 +127,8 @@ class UserInformationTab extends ConsumerWidget { ), ClickableText( leftText: '', - clickableText: sharedKey ?? S.of(context)!.notAvailable, + clickableText: + chatKeys?.conv.private ?? S.of(context)!.notAvailable, ), ], ), diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 3dca1109d..21f2d850b 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -10,7 +10,7 @@ import 'package:mostro_mobile/features/chat/providers/active_chat_screens_provid import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/chat/utils/message_type_helpers.dart'; -import 'package:mostro_mobile/services/dispute_chat_cursor_store.dart'; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; import 'package:mostro_mobile/shared/mixins/media_cache_mixin.dart'; @@ -216,32 +216,27 @@ class DisputeChatNotifier extends StateNotifier with MediaCach // Check for duplicate outer events (relay re-deliveries) final wrapperEventId = event.id; if (wrapperEventId == null) return; + // Already on disk means a relay re-delivery, an own echo, or an event + // the background service stored while the app slept. Keep processing: + // state is keyed by inner id, so only the write is redundant. final eventStore = ref.read(eventStorageProvider); - if (await eventStore.hasItem(wrapperEventId)) return; + final alreadyStored = await eventStore.hasItem(wrapperEventId); - // Store the outer event (encrypted) to disk — same pattern as P2P chat - await eventStore.putItem( - wrapperEventId, - { - 'id': wrapperEventId, - 'created_at': event.createdAt!.millisecondsSinceEpoch ~/ 1000, - 'kind': event.kind, - 'content': event.content, - 'pubkey': event.pubkey, - 'sig': event.sig, - 'tags': event.tags, - 'type': 'dispute_chat', - 'dispute_id': disputeId, - }, - ); - if (!mounted) return; - - // Unwrap and authenticate: the inner event must be signed by a - // conversation party (trade key or admin pubkey) + // Unwrap and authenticate BEFORE persisting: the signature is not part + // of the event id, so storing an unverified copy would let a corrupted + // duplicate occupy the id and dedup away the valid one for good final unwrappedEvent = await event.chatUnwrap( chatKeys, session.disputeChatAllowedSigners, ); + + // Store the outer event (encrypted) to disk — same pattern as P2P chat + if (!alreadyStored) { + await eventStore.putItem( + wrapperEventId, + event.disputeChatRecord(disputeId), + ); + } if (!mounted) return; // Advance the persisted since cursor only after the event is accepted @@ -408,12 +403,11 @@ class DisputeChatNotifier extends StateNotifier with MediaCach return; } - // Create the inner event (kind 1, no tags per the chat spec) FIRST to - // get the real event ID used for optimistic UI and echo deduplication - final rumor = NostrEvent.fromPartialData( - keyPairs: session.tradeKey, + // Create the inner event (kind 1 with a `u` nonce tag) FIRST to get + // the real event ID used for optimistic UI and echo deduplication + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: session.tradeKey, content: text, - kind: 1, ); final rumorId = rumor.id; @@ -453,17 +447,7 @@ class DisputeChatNotifier extends StateNotifier with MediaCach final eventStore = ref.read(eventStorageProvider); await eventStore.putItem( wrappedEvent.id!, - { - 'id': wrappedEvent.id, - 'created_at': wrappedEvent.createdAt!.millisecondsSinceEpoch ~/ 1000, - 'kind': wrappedEvent.kind, - 'content': wrappedEvent.content, - 'pubkey': wrappedEvent.pubkey, - 'sig': wrappedEvent.sig, - 'tags': wrappedEvent.tags, - 'type': 'dispute_chat', - 'dispute_id': disputeId, - }, + wrappedEvent.disputeChatRecord(disputeId), ); if (!mounted) return; diff --git a/lib/features/notifications/services/background_notification_service.dart b/lib/features/notifications/services/background_notification_service.dart index 79cacc608..6754655cc 100644 --- a/lib/features/notifications/services/background_notification_service.dart +++ b/lib/features/notifications/services/background_notification_service.dart @@ -214,8 +214,10 @@ Future _decryptAndProcessEvent(NostrEvent event) async { final sessions = await _loadSessionsFromDatabase(); - // Dispute chat (kind 14 envelope): the outer author is the K_sign key - // derived from the admin shared key, so match by author, not recipient. + // Chat (kind 14 envelope): the outer author is the K_sign key derived + // from the conversation's shared key, so match by author, not recipient. + // These branches must run before the tradeKey match: Mostro protocol + // events are also kind 14 but authored by the node. if (event.kind == 14) { for (final session in sessions) { final adminShared = session.adminSharedKey; @@ -225,6 +227,14 @@ Future _decryptAndProcessEvent(NostrEvent event) async { return _processAdminDm(event, session, chatKeys); } } + for (final session in sessions) { + final shared = session.sharedKey; + if (shared == null) continue; + final chatKeys = ChatKeys.fromSharedKey(shared); + if (event.pubkey == chatKeys.sign.public) { + return _processPeerChat(event, session, chatKeys); + } + } } // Standard Mostro message: match by tradeKey @@ -237,20 +247,6 @@ Future _decryptAndProcessEvent(NostrEvent event) async { return _handleTradeKeyEvent(event, matchingSession); } - // P2P chat: match by sharedKey.public. - // Require non-null on both sides to prevent spurious null == null matches. - final chatMatch = sessions.cast().firstWhere( - (s) { - final sharedPub = s?.sharedKey?.public; - return sharedPub != null && sharedPub == recipient; - }, - orElse: () => null, - ); - - if (chatMatch != null) { - return _handleP2PChatEvent(event, chatMatch); - } - return null; } catch (e) { logger.e('Decrypt error: $e'); @@ -268,6 +264,18 @@ Future _processAdminDm( chatKeys, session.disputeChatAllowedSigners, ); + + // Durable accepted-event marker, written only after chatUnwrap accepts: + // dedups across background-service restarts and lets the foreground load + // the message from disk instead of depending on a relay refetch + final disputeId = session.disputeId; + if (disputeId != null && event.id != null) { + await bg.persistChatEventFromBackground?.call( + event.id!, + event.disputeChatRecord(disputeId), + ); + } + if (unwrapped.content == null || unwrapped.content!.isEmpty) { return null; } @@ -406,9 +414,14 @@ Future _maybeUpdateSessionWithPeer( logger.i('Background persisted peer for order ${session.orderId}'); - final sharedKeyPublic = session.sharedKey?.public; - if (sharedKeyPublic != null) { - bg.addChatSubscriptionFromBackground?.call(sharedKeyPublic); + // The live chat subscription filters by the conversation's K_sign author; + // the order id lets it pick up this conversation's persisted since cursor + final shared = session.sharedKey; + if (shared != null) { + bg.addChatSubscriptionFromBackground?.call( + ChatKeys.fromSharedKey(shared).sign.public, + session.orderId, + ); } } catch (e, stackTrace) { logger.e( @@ -418,14 +431,29 @@ Future _maybeUpdateSessionWithPeer( } } -/// Handle P2P chat events matched by sharedKey -Future _handleP2PChatEvent(NostrEvent event, Session session) async { +/// Handle P2P chat events matched by the peer conversation's K_sign author +Future _processPeerChat( + NostrEvent event, + Session session, + ChatKeys chatKeys, +) async { try { - final sharedKey = session.sharedKey; - if (sharedKey == null) { - return null; + final decryptedEvent = await event.chatUnwrap( + chatKeys, + session.peerChatAllowedSigners, + ); + + // Durable accepted-event marker, written only after chatUnwrap accepts: + // dedups across background-service restarts and lets the foreground load + // the message from disk instead of depending on a relay refetch + final orderId = session.orderId; + if (orderId != null && event.id != null) { + await bg.persistChatEventFromBackground?.call( + event.id!, + event.peerChatRecord(orderId), + ); } - final decryptedEvent = await event.p2pUnwrap(sharedKey); + if (decryptedEvent.content == null || decryptedEvent.content!.isEmpty) { return null; } @@ -438,7 +466,7 @@ Future _handleP2PChatEvent(NostrEvent event, Session session) as } if (session.orderId == null) { - logger.w('P2P chat received but session has no orderId (recipient: ${event.recipient}), skipping notification'); + logger.w('P2P chat received but session has no orderId, skipping notification'); return null; } diff --git a/lib/features/subscriptions/subscription_manager.dart b/lib/features/subscriptions/subscription_manager.dart index c17aa5413..73f4b29d2 100644 --- a/lib/features/subscriptions/subscription_manager.dart +++ b/lib/features/subscriptions/subscription_manager.dart @@ -12,7 +12,7 @@ import 'package:mostro_mobile/features/mostro/transport.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/features/subscriptions/subscription.dart'; import 'package:mostro_mobile/features/subscriptions/subscription_type.dart'; -import 'package:mostro_mobile/services/dispute_chat_cursor_store.dart'; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; @@ -159,9 +159,9 @@ class SubscriptionManager { } try { - // Pre-warm persisted cursors so the dispute chat filter — built + // Pre-warm persisted cursors so the chat filters — built // synchronously and later persisted for the background service — - // sees durable state even on a cold start + // see durable state even on a cold start if (type == SubscriptionType.disputeChat) { final disputeIds = sessions .where((s) => s.adminSharedKey != null) @@ -169,6 +169,13 @@ class SubscriptionManager { .whereType(); await ref.read(disputeChatCursorStoreProvider).warmUp(disputeIds); } + if (type == SubscriptionType.chat) { + final orderIds = sessions + .where((s) => s.sharedKey != null) + .map((s) => s.orderId) + .whereType(); + await ref.read(chatCursorStoreProvider).warmUp(orderIds); + } final filter = _createFilterForType(type, sessions); if (filter == null) { @@ -208,18 +215,28 @@ class SubscriptionManager { ref.read(settingsProvider).mostroPublicKey, ); case SubscriptionType.chat: - if (sessions.isEmpty) { - return null; - } - if (sessions.where((s) => s.sharedKey?.public != null).isEmpty) { - return null; - } - return NostrFilter( - kinds: [1059], - p: sessions - .where((s) => s.sharedKey?.public != null) - .map((s) => s.sharedKey!.public) - .toList(), + // Kind 14 chat envelope: filter by the K_sign authors derived from + // each peer shared key (never by #p — third-party flooding) + final chatSessions = + sessions.where((s) => s.sharedKey != null).toList(); + if (chatSessions.isEmpty) return null; + final chatSignKeys = chatSessions + .map((s) => ChatKeys.fromSharedKey(s.sharedKey!).sign.public) + .toList(); + // Shared filter across conversations: earliest persisted cursor, + // falling back to the default lookback (wider window; dedup absorbs) + final chatCursorStore = ref.read(chatCursorStoreProvider); + final chatDefaultSince = DateTime.now() + .subtract(NostrEventExtensions.chatDefaultLookback); + final chatSince = chatSessions + .map((s) => s.orderId == null + ? chatDefaultSince + : (chatCursorStore.cachedSinceFor(s.orderId!) ?? + chatDefaultSince)) + .reduce((a, b) => a.isBefore(b) ? a : b); + return NostrEventExtensions.chatSubscriptionFilter( + signPubkeys: chatSignKeys, + since: chatSince, ); case SubscriptionType.disputeChat: // Kind 14 chat envelope: filter by the K_sign authors derived from @@ -240,11 +257,9 @@ class SubscriptionManager { ? defaultSince : (cursorStore.cachedSinceFor(s.disputeId!) ?? defaultSince)) .reduce((a, b) => a.isBefore(b) ? a : b); - return NostrFilter( - kinds: [14], - authors: signKeys, + return NostrEventExtensions.chatSubscriptionFilter( + signPubkeys: signKeys, since: since, - limit: NostrEventExtensions.chatDefaultLimit, ); case SubscriptionType.relayList: // Relay list subscriptions are handled separately via subscribeToMostroRelayList diff --git a/lib/services/dispute_chat_cursor_store.dart b/lib/services/chat_cursor_store.dart similarity index 50% rename from lib/services/dispute_chat_cursor_store.dart rename to lib/services/chat_cursor_store.dart index bd11cf0dd..32f786f4c 100644 --- a/lib/services/dispute_chat_cursor_store.dart +++ b/lib/services/chat_cursor_store.dart @@ -2,98 +2,121 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; import 'package:shared_preferences/shared_preferences.dart'; -/// Persists the per-conversation `since` cursor for dispute chat -/// subscriptions, as the chat spec requires: "subscribe with `since` set to -/// the last processed timestamp, persisted locally, together with a limit". +/// Persists the per-conversation `since` cursor for chat subscriptions, as +/// the chat spec requires: "subscribe with `since` set to the last processed +/// timestamp, persisted locally, together with a limit". /// /// The cursor advances only after chatUnwrap accepts an event, clamped to /// min(accepted_timestamp, local_now) so a future-dated event cannot /// suppress later messages. Subscriptions subtract [cursorOverlap] so an /// event late-delivered by a slow relay is not filtered out forever; /// outer-id dedup absorbs the re-delivered tail. -class DisputeChatCursorStore { - static const _keyPrefix = 'dispute_chat_since_'; - +/// +/// One instance per conversation namespace: [keyPrefix] scopes the +/// SharedPreferences keys (dispute chat by disputeId, peer chat by orderId). +class ChatCursorStore { /// Overlap window subtracted from the cursor when subscribing. static const cursorOverlap = Duration(minutes: 10); + /// Dispute chat namespace, keyed by disputeId. The prefix predates the + /// generalization; keeping it preserves cursors stored by older builds. + static const disputeKeyPrefix = 'dispute_chat_since_'; + + /// Peer (buyer-seller) chat namespace, keyed by orderId. Shared with the + /// background isolate, which builds its own store without Riverpod. + static const peerKeyPrefix = 'chat_since_'; + + final String _keyPrefix; final SharedPreferencesAsync _prefs; final Map _cache = {}; - /// Per-dispute chain serializing advance() so concurrent calls cannot + /// Per-conversation chain serializing advance() so concurrent calls cannot /// interleave their read-compare-write and regress the cursor. final Map> _advanceQueue = {}; - DisputeChatCursorStore(this._prefs); + ChatCursorStore(this._prefs, {required String keyPrefix}) + : _keyPrefix = keyPrefix; /// Clamp an accepted event timestamp to the local clock. static DateTime clamp(DateTime accepted, DateTime now) => accepted.isAfter(now) ? now : accepted; /// Last processed timestamp for a conversation, or null if none stored. - Future cursorFor(String disputeId) async { - final cached = _cache[disputeId]; + Future cursorFor(String conversationId) async { + final cached = _cache[conversationId]; if (cached != null) return cached; - final secs = await _prefs.getInt('$_keyPrefix$disputeId'); + final secs = await _prefs.getInt('$_keyPrefix$conversationId'); if (secs == null) return null; final cursor = DateTime.fromMillisecondsSinceEpoch(secs * 1000); - _cache[disputeId] = cursor; + _cache[conversationId] = cursor; return cursor; } /// Subscription `since` for a conversation: the cursor minus the overlap /// window, or null when no cursor is stored yet (callers fall back to the /// default lookback). - Future sinceFor(String disputeId) async { - final cursor = await cursorFor(disputeId); + Future sinceFor(String conversationId) async { + final cursor = await cursorFor(conversationId); return cursor?.subtract(cursorOverlap); } /// Synchronous variant for call sites that build filters synchronously. /// Returns null when the cursor is not in memory yet — call [warmUp] /// first so persisted cursors are visible after a cold start. - DateTime? cachedSinceFor(String disputeId) => - _cache[disputeId]?.subtract(cursorOverlap); + DateTime? cachedSinceFor(String conversationId) => + _cache[conversationId]?.subtract(cursorOverlap); /// Load the persisted cursors for the given conversations into the /// in-memory cache, so synchronous filter builders see durable state. - Future warmUp(Iterable disputeIds) async { - for (final disputeId in disputeIds) { - await cursorFor(disputeId); + Future warmUp(Iterable conversationIds) async { + for (final conversationId in conversationIds) { + await cursorFor(conversationId); } } /// Advance the cursor after an accepted event. Monotonic (never moves - /// backward), clamped to the local clock, and serialized per dispute. + /// backward), clamped to the local clock, and serialized per conversation. Future advance( - String disputeId, + String conversationId, DateTime accepted, { DateTime? now, }) { - final previous = _advanceQueue[disputeId] ?? Future.value(); + final previous = _advanceQueue[conversationId] ?? Future.value(); final next = previous .catchError((_) {}) - .then((_) => _advanceSerialized(disputeId, accepted, now: now)); - _advanceQueue[disputeId] = next; + .then((_) => _advanceSerialized(conversationId, accepted, now: now)); + _advanceQueue[conversationId] = next; return next; } Future _advanceSerialized( - String disputeId, + String conversationId, DateTime accepted, { DateTime? now, }) async { final clamped = clamp(accepted, now ?? DateTime.now()); - final current = await cursorFor(disputeId); + final current = await cursorFor(conversationId); if (current != null && !clamped.isAfter(current)) return; - _cache[disputeId] = clamped; + _cache[conversationId] = clamped; await _prefs.setInt( - '$_keyPrefix$disputeId', + '$_keyPrefix$conversationId', clamped.millisecondsSinceEpoch ~/ 1000, ); } } -final disputeChatCursorStoreProvider = Provider( - (ref) => DisputeChatCursorStore(ref.watch(sharedPreferencesProvider)), +/// Dispute chat cursors, keyed by disputeId. +final disputeChatCursorStoreProvider = Provider( + (ref) => ChatCursorStore( + ref.watch(sharedPreferencesProvider), + keyPrefix: ChatCursorStore.disputeKeyPrefix, + ), +); + +/// Peer (buyer-seller) chat cursors, keyed by orderId. +final chatCursorStoreProvider = Provider( + (ref) => ChatCursorStore( + ref.watch(sharedPreferencesProvider), + keyPrefix: ChatCursorStore.peerKeyPrefix, + ), ); diff --git a/lib/services/push_notification_service.dart b/lib/services/push_notification_service.dart index 4ed7a44ce..38a6c5fe3 100644 --- a/lib/services/push_notification_service.dart +++ b/lib/services/push_notification_service.dart @@ -226,9 +226,10 @@ class PushNotificationService { /// Wake the peer's device by triggering a silent push via `/api/notify`. /// /// Used after sending a P2P chat message: the push server's Nostr listener - /// only matches `kind 1059` events on the recipient's `tradeKey.public`, - /// and P2P chat events use `sharedKey.public`. This sender-triggered call - /// closes that gap without registering shared keys server-side. + /// only matches events addressed to the recipient's `tradeKey.public`, and + /// the kind-14 chat envelope is authored by `K_sign` and tagged to + /// `pub(K_conv)`. This sender-triggered call closes that gap without + /// registering conversation keys server-side. /// /// Intentionally NOT gated on `isPushEnabledInSettings`: that flag is the /// local *receive* preference and controls this device's own token diff --git a/test/data/models/nostr_event_chat_test.dart b/test/data/models/nostr_event_chat_test.dart index bb5389672..21fc93582 100644 --- a/test/data/models/nostr_event_chat_test.dart +++ b/test/data/models/nostr_event_chat_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/shared/utils/chat_keys.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; @@ -36,6 +37,41 @@ void main() { ); } + group('createChatRumor', () { + test('builds a signed kind 1 event with a single u nonce tag', () { + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: alice, + content: 'hello with nonce', + ); + + expect(rumor.kind, equals(1)); + expect(rumor.pubkey, equals(alice.public)); + expect(rumor.id, isNotNull); + expect(rumor.sig, isNotNull); + + final uTags = rumor.tags!.where((t) => t[0] == 'u').toList(); + expect(rumor.tags!.length, equals(1)); + expect(uTags.length, equals(1)); + expect(uTags[0][1], matches(RegExp(r'^[0-9a-f]{16}$'))); + }); + + test('identical content at the same timestamp yields distinct ids', () { + final createdAt = DateTime.now(); + final first = NostrEventExtensions.createChatRumor( + senderKeys: alice, + content: 'ok', + createdAt: createdAt, + ); + final second = NostrEventExtensions.createChatRumor( + senderKeys: alice, + content: 'ok', + createdAt: createdAt, + ); + + expect(first.id, isNot(equals(second.id))); + }); + }); + group('chatWrap', () { test('produces a kind 14 envelope with the expected shape', () async { final inner = buildInner(alice, 'hello from the test'); @@ -74,6 +110,20 @@ void main() { expect(unwrapped.id, equals(inner.id)); }); + test('accepts a rumor carrying the u nonce tag', () async { + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: alice, + content: 'nonce round-trip', + ); + final wrapped = await rumor.chatWrap(chatKeys); + + final unwrapped = await wrapped.chatUnwrap(chatKeys, allowedSigners); + + expect(unwrapped.content, equals('nonce round-trip')); + expect(unwrapped.id, equals(rumor.id)); + expect(unwrapped.tags!.where((t) => t[0] == 'u').length, equals(1)); + }); + test('works in both directions with the same derived keys', () async { final bobChatKeys = ChatKeys.fromSharedKey( NostrUtils.computeSharedKey(bobPrivate, alice.public), @@ -323,4 +373,38 @@ void main() { ); }); }); + + group('chatSubscriptionFilter', () { + final since = DateTime.fromMillisecondsSinceEpoch(1700000000 * 1000); + + test('matches by author and bounds the backlog', () { + final filter = NostrEventExtensions.chatSubscriptionFilter( + signPubkeys: [chatKeys.sign.public], + since: since, + ); + + expect(filter.kinds, [14]); + expect(filter.authors, [chatKeys.sign.public]); + expect(filter.since, since); + expect(filter.limit, NostrEventExtensions.chatDefaultLimit); + // Filtering by #p would let any third party flood the subscription + expect(filter.p, isNull); + }); + + test('survives the background persist/restore round-trip', () { + final filter = NostrEventExtensions.chatSubscriptionFilter( + signPubkeys: [chatKeys.sign.public], + since: since, + ); + + final restored = NostrFilterX.fromJsonSafe( + jsonDecode(jsonEncode(filter.toMap())) as Map, + ); + + expect(restored.kinds, filter.kinds); + expect(restored.authors, filter.authors); + expect(restored.since, filter.since); + expect(restored.limit, filter.limit); + }); + }); } diff --git a/test/data/models/nostr_event_wrap_test.dart b/test/data/models/nostr_event_wrap_test.dart index 83dc2b5c5..b936ec65e 100644 --- a/test/data/models/nostr_event_wrap_test.dart +++ b/test/data/models/nostr_event_wrap_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/core/config.dart'; @@ -17,8 +19,31 @@ void main() { final senderPublicKey = keyDerivator.privateToPublicKey(senderPrivKey); final wrongPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 3); - group('p2pWrap / p2pUnwrap round-trip', () { - test('wraps and unwraps a text message correctly', () async { + /// Reproduces the retired pre-migration wire format (1-layer gift wrap: + /// ephemeral key, NIP-44 to the shared key pubkey, kind 1059) so the + /// p2pUnwrap path that reads stored history stays covered. + Future legacyWrap( + NostrEvent innerEvent, + String receiverPubkey, + ) async { + final ephemeralKeyPair = NostrUtils.generateKeyPair(); + final encryptedContent = await NostrUtils.encryptNIP44( + jsonEncode(innerEvent.toMap()), + ephemeralKeyPair.private, + receiverPubkey, + ); + return NostrEvent.fromPartialData( + kind: 1059, + content: encryptedContent, + keyPairs: ephemeralKeyPair, + tags: [ + ["p", receiverPubkey], + ], + ); + } + + group('p2pUnwrap (legacy stored history)', () { + test('unwraps a legacy-wrapped text message correctly', () async { // Compute shared key from both sides final senderSharedKey = NostrUtils.computeSharedKey(senderPrivKey, receiverPublicKey); @@ -38,11 +63,7 @@ void main() { ], ); - // Wrap with p2pWrap - final wrappedEvent = await innerEvent.p2pWrap( - NostrKeyPairs(private: senderPrivKey), - senderSharedKey.public, - ); + final wrappedEvent = await legacyWrap(innerEvent, senderSharedKey.public); // Unwrap with receiver's shared key final unwrapped = await wrappedEvent.p2pUnwrap(receiverSharedKey); @@ -71,10 +92,7 @@ void main() { ], ); - final wrappedEvent = await innerEvent.p2pWrap( - NostrKeyPairs(private: senderPrivKey), - sharedKey.public, - ); + final wrappedEvent = await legacyWrap(innerEvent, sharedKey.public); // Unwrap with wrong key should throw expect( @@ -83,40 +101,6 @@ void main() { ); }); - test('wrapped event has kind 1059 and correct p tag', () async { - final sharedKey = - NostrUtils.computeSharedKey(senderPrivKey, receiverPublicKey); - - final innerEvent = NostrEvent.fromPartialData( - keyPairs: NostrKeyPairs(private: senderPrivKey), - content: 'Test message', - kind: 1, - tags: [ - ["p", sharedKey.public], - ], - ); - - final wrappedEvent = await innerEvent.p2pWrap( - NostrKeyPairs(private: senderPrivKey), - sharedKey.public, - ); - - // Wrapper should be kind 1059 - expect(wrappedEvent.kind, equals(1059)); - - // p tag should point to shared key pubkey - final pTag = wrappedEvent.tags?.firstWhere( - (tag) => tag.isNotEmpty && tag[0] == 'p', - orElse: () => [], - ); - expect(pTag, isNotNull); - expect(pTag!.length, greaterThanOrEqualTo(2)); - expect(pTag[1], equals(sharedKey.public)); - - // Wrapper pubkey should be ephemeral (different from sender) - expect(wrappedEvent.pubkey, isNot(equals(senderPublicKey))); - }); - test('plain text content round-trips (no JSON wrapper needed)', () async { final sharedKey = NostrUtils.computeSharedKey(senderPrivKey, receiverPublicKey); @@ -135,10 +119,7 @@ void main() { ], ); - final wrapped = await innerEvent.p2pWrap( - NostrKeyPairs(private: senderPrivKey), - sharedKey.public, - ); + final wrapped = await legacyWrap(innerEvent, sharedKey.public); final unwrapped = await wrapped.p2pUnwrap(receiverSharedKey); diff --git a/test/data/models/session_chat_signers_test.dart b/test/data/models/session_chat_signers_test.dart new file mode 100644 index 000000000..13735de76 --- /dev/null +++ b/test/data/models/session_chat_signers_test.dart @@ -0,0 +1,59 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/data/models/session.dart'; + +/// The chat allow-lists gate which inner-event signers a conversation +/// accepts (chatUnwrap step 9). These pure tests lock their contents in. +void main() { + final tradeKey = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000001', + ); + final peerKey = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000002', + ); + final adminKey = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000003', + ); + + Session makeSession() => Session( + masterKey: tradeKey, + tradeKey: tradeKey, + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.parse('2026-08-18T12:00:00.000'), + orderId: 'order-1', + ); + + group('Session.peerChatAllowedSigners', () { + test('contains only the own trade key while no peer is set', () { + expect(makeSession().peerChatAllowedSigners, equals([tradeKey.public])); + }); + + test('contains both trade keys once the peer is set', () { + final session = makeSession()..peer = Peer(publicKey: peerKey.public); + + expect( + session.peerChatAllowedSigners, + equals([tradeKey.public, peerKey.public]), + ); + // Setting the peer also derives the conversation's ECDH shared key + expect(session.sharedKey, isNotNull); + }); + }); + + group('Session.disputeChatAllowedSigners', () { + test('contains both parties once the admin peer is set', () { + final session = makeSession()..setAdminPeer(adminKey.public); + + expect( + session.disputeChatAllowedSigners, + equals([tradeKey.public, adminKey.public]), + ); + expect(session.adminSharedKey, isNotNull); + }); + }); +} diff --git a/test/features/chat/chat_room_notifier_security_test.dart b/test/features/chat/chat_room_notifier_security_test.dart new file mode 100644 index 000000000..181d18aa4 --- /dev/null +++ b/test/features/chat/chat_room_notifier_security_test.dart @@ -0,0 +1,217 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/event_storage.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_rooms_notifier.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart'; +import 'package:mostro_mobile/shared/utils/chat_keys.dart'; +import 'package:sembast/sembast_memory.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Inert chat-list notifier so the handler's refreshChatList side call does +/// not drag the real session/key-manager provider chain into the test. +class _StubChatRoomsNotifier extends ChatRoomsNotifier { + _StubChatRoomsNotifier(super.ref); + + @override + Future loadChats() async {} + + @override + Future refreshChatList() async {} +} + +/// Minimal in-memory double for the two methods the cursor store uses. +class _FakeSharedPreferencesAsync implements SharedPreferencesAsync { + final Map ints = {}; + + @override + Future getInt(String key) async => ints[key]; + + @override + Future setInt(String key, int value) async { + ints[key] = value; + } + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const orderId = 'a4b7c9e1-0000-4000-8000-chat-security'; + final ownKey = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000001', + ); + final peerKey = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000002', + ); + + late Session session; + late EventStorage eventStorage; + late ProviderContainer container; + late ChatRoomNotifier notifier; + late ChatKeys chatKeys; + + final chatRoomProvider = StateNotifierProvider( + (ref) => ChatRoomNotifier( + ChatRoom(orderId: orderId, messages: []), + orderId, + ref, + ), + ); + + /// A copy of [event] whose signature is well-formed but signed by a key + /// that is not K_sign. The event id is unchanged: signatures are not part + /// of the Nostr event id. + NostrEvent corruptSignature(NostrEvent event) { + return NostrEvent( + id: event.id, + kind: event.kind, + content: event.content, + sig: _forgerKey.sign(event.id!), + pubkey: event.pubkey, + createdAt: event.createdAt, + tags: event.tags, + ); + } + + setUp(() async { + session = Session( + masterKey: ownKey, + tradeKey: ownKey, + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: orderId, + )..peer = Peer(publicKey: peerKey.public); + chatKeys = ChatKeys.fromSharedKey(session.sharedKey!); + + final db = + await newDatabaseFactoryMemory().openDatabase('chat_security.db'); + eventStorage = EventStorage(db: db); + + container = ProviderContainer( + overrides: [ + sessionProvider(orderId).overrideWith((ref) => session), + eventStorageProvider.overrideWithValue(eventStorage), + chatCursorStoreProvider.overrideWithValue( + ChatCursorStore(_FakeSharedPreferencesAsync(), + keyPrefix: 'chat_since_'), + ), + chatRoomsNotifierProvider.overrideWith( + (ref) => _StubChatRoomsNotifier(ref), + ), + ], + ); + notifier = container.read(chatRoomProvider.notifier); + }); + + tearDown(() { + container.dispose(); + }); + + group('envelope authentication before persistence', () { + test( + 'a signature-corrupted copy delivered first does not suppress the ' + 'valid event with the same id', () async { + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: peerKey, + content: 'hello from the peer', + ); + final valid = await rumor.chatWrap(chatKeys); + final corrupted = corruptSignature(valid); + + // Malicious relay wins the race with the corrupted copy + await notifier.handleChatEvent(corrupted); + + expect(await eventStorage.hasItem(valid.id!), isFalse, + reason: 'an unauthenticated envelope must never be persisted'); + expect(container.read(chatRoomProvider).messages, isEmpty); + + // Honest relay delivers the valid copy afterwards + await notifier.handleChatEvent(valid); + + expect(await eventStorage.hasItem(valid.id!), isTrue); + final messages = container.read(chatRoomProvider).messages; + expect(messages, hasLength(1)); + expect(messages.single.content, equals('hello from the peer')); + expect(messages.single.id, equals(rumor.id)); + }); + + test('duplicate deliveries of a valid event are still deduplicated', + () async { + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: peerKey, + content: 'once only', + ); + final valid = await rumor.chatWrap(chatKeys); + + await notifier.handleChatEvent(valid); + await notifier.handleChatEvent(valid); + + expect(container.read(chatRoomProvider).messages, hasLength(1)); + }); + + test( + 'an event the background already persisted still reaches the UI', + () async { + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: peerKey, + content: 'stored while the app slept', + ); + final valid = await rumor.chatWrap(chatKeys); + + // The background service persists accepted envelopes but cannot touch + // the foreground state, so the record is on disk before the notifier + // ever sees the event + await eventStorage.putItem(valid.id!, valid.peerChatRecord(orderId)); + + await notifier.handleChatEvent(valid); + + final messages = container.read(chatRoomProvider).messages; + expect(messages, hasLength(1), + reason: 'a stored-but-unseen event must not be dropped by dedup'); + expect(messages.single.content, equals('stored while the app slept')); + }); + + test('an event from a stranger author is ignored and not persisted', + () async { + final stranger = _forgerKey; + final rumor = NostrEventExtensions.createChatRumor( + senderKeys: peerKey, + content: 'wrong author', + ); + final wrapped = await rumor.chatWrap(chatKeys); + final wrongAuthor = NostrEvent.fromPartialData( + kind: 14, + content: wrapped.content!, + keyPairs: stranger, + tags: wrapped.tags, + createdAt: wrapped.createdAt, + ); + + await notifier.handleChatEvent(wrongAuthor); + + expect(await eventStorage.hasItem(wrongAuthor.id!), isFalse); + expect(container.read(chatRoomProvider).messages, isEmpty); + }); + }); +} + +/// Deterministic third-party key used to forge signatures in tests. +final _forgerKey = NostrKeyPairs( + private: + '0000000000000000000000000000000000000000000000000000000000000003', +); diff --git a/test/features/disputes/dispute_chat_cursor_store_test.dart b/test/services/chat_cursor_store_test.dart similarity index 52% rename from test/features/disputes/dispute_chat_cursor_store_test.dart rename to test/services/chat_cursor_store_test.dart index 581a0a2f2..5f5f49eaa 100644 --- a/test/features/disputes/dispute_chat_cursor_store_test.dart +++ b/test/services/chat_cursor_store_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:mostro_mobile/services/dispute_chat_cursor_store.dart'; +import 'package:mostro_mobile/services/chat_cursor_store.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Minimal in-memory double for the two methods the store uses. @@ -21,42 +21,45 @@ class _FakeSharedPreferencesAsync implements SharedPreferencesAsync { void main() { late _FakeSharedPreferencesAsync prefs; - late DisputeChatCursorStore store; + late ChatCursorStore store; - const disputeId = 'dispute-123'; + const keyPrefix = 'dispute_chat_since_'; + const conversationId = 'dispute-123'; final now = DateTime.fromMillisecondsSinceEpoch(1755000000 * 1000); setUp(() { prefs = _FakeSharedPreferencesAsync(); - store = DisputeChatCursorStore(prefs); + store = ChatCursorStore(prefs, keyPrefix: keyPrefix); }); - group('DisputeChatCursorStore', () { + group('ChatCursorStore', () { test('returns null cursor and since for an unknown conversation', () async { - expect(await store.cursorFor(disputeId), isNull); - expect(await store.sinceFor(disputeId), isNull); - expect(store.cachedSinceFor(disputeId), isNull); + expect(await store.cursorFor(conversationId), isNull); + expect(await store.sinceFor(conversationId), isNull); + expect(store.cachedSinceFor(conversationId), isNull); }); - test('advance persists the accepted timestamp', () async { + test('advance persists the accepted timestamp under the key prefix', + () async { final accepted = now.subtract(const Duration(minutes: 5)); - await store.advance(disputeId, accepted, now: now); + await store.advance(conversationId, accepted, now: now); - expect(await store.cursorFor(disputeId), equals(accepted)); + expect(await store.cursorFor(conversationId), equals(accepted)); expect( - await store.sinceFor(disputeId), - equals(accepted.subtract(DisputeChatCursorStore.cursorOverlap)), + await store.sinceFor(conversationId), + equals(accepted.subtract(ChatCursorStore.cursorOverlap)), ); + expect(prefs.ints.keys, everyElement(startsWith(keyPrefix))); expect(prefs.ints, isNotEmpty); }); test('advance clamps a future-dated timestamp to the local clock', () async { final farFuture = now.add(const Duration(days: 30)); - await store.advance(disputeId, farFuture, now: now); + await store.advance(conversationId, farFuture, now: now); - expect(await store.cursorFor(disputeId), equals(now)); + expect(await store.cursorFor(conversationId), equals(now)); }); test('advance is monotonic and never moves the cursor backward', @@ -64,18 +67,18 @@ void main() { final newer = now.subtract(const Duration(minutes: 1)); final older = now.subtract(const Duration(hours: 2)); - await store.advance(disputeId, newer, now: now); - await store.advance(disputeId, older, now: now); + await store.advance(conversationId, newer, now: now); + await store.advance(conversationId, older, now: now); - expect(await store.cursorFor(disputeId), equals(newer)); + expect(await store.cursorFor(conversationId), equals(newer)); }); test('cursor survives a new store instance (persistence)', () async { final accepted = now.subtract(const Duration(minutes: 5)); - await store.advance(disputeId, accepted, now: now); + await store.advance(conversationId, accepted, now: now); - final freshStore = DisputeChatCursorStore(prefs); - final restored = await freshStore.cursorFor(disputeId); + final freshStore = ChatCursorStore(prefs, keyPrefix: keyPrefix); + final restored = await freshStore.cursorFor(conversationId); // Stored with second precision expect( @@ -86,11 +89,11 @@ void main() { test('cachedSinceFor is available after loading or advancing', () async { final accepted = now.subtract(const Duration(minutes: 5)); - await store.advance(disputeId, accepted, now: now); + await store.advance(conversationId, accepted, now: now); expect( - store.cachedSinceFor(disputeId), - equals(accepted.subtract(DisputeChatCursorStore.cursorOverlap)), + store.cachedSinceFor(conversationId), + equals(accepted.subtract(ChatCursorStore.cursorOverlap)), ); }); @@ -102,31 +105,31 @@ void main() { // Both calls start before either completes; serialization must // prevent the older timestamp from overwriting the newer one await Future.wait([ - store.advance(disputeId, newer, now: now), - store.advance(disputeId, older, now: now), + store.advance(conversationId, newer, now: now), + store.advance(conversationId, older, now: now), ]); - expect(await store.cursorFor(disputeId), equals(newer)); - expect(store.cachedSinceFor(disputeId), - equals(newer.subtract(DisputeChatCursorStore.cursorOverlap))); + expect(await store.cursorFor(conversationId), equals(newer)); + expect(store.cachedSinceFor(conversationId), + equals(newer.subtract(ChatCursorStore.cursorOverlap))); }); test('warmUp loads persisted cursors into a cold cache', () async { final accepted = now.subtract(const Duration(minutes: 5)); - await store.advance(disputeId, accepted, now: now); + await store.advance(conversationId, accepted, now: now); // Fresh instance simulates a cold start: cache empty, prefs populated - final coldStore = DisputeChatCursorStore(prefs); - expect(coldStore.cachedSinceFor(disputeId), isNull); + final coldStore = ChatCursorStore(prefs, keyPrefix: keyPrefix); + expect(coldStore.cachedSinceFor(conversationId), isNull); - await coldStore.warmUp([disputeId, 'unknown-dispute']); + await coldStore.warmUp([conversationId, 'unknown-dispute']); - final since = coldStore.cachedSinceFor(disputeId); + final since = coldStore.cachedSinceFor(conversationId); expect(since, isNotNull); expect( since!.millisecondsSinceEpoch ~/ 1000, equals(accepted - .subtract(DisputeChatCursorStore.cursorOverlap) + .subtract(ChatCursorStore.cursorOverlap) .millisecondsSinceEpoch ~/ 1000), ); @@ -143,12 +146,26 @@ void main() { expect(await store.cursorFor('dispute-b'), equals(b)); }); + test('stores with different prefixes do not collide on the same id', + () async { + final peerStore = ChatCursorStore(prefs, keyPrefix: 'chat_since_'); + final disputeCursor = now.subtract(const Duration(minutes: 5)); + final peerCursor = now.subtract(const Duration(hours: 3)); + + await store.advance('order-1', disputeCursor, now: now); + await peerStore.advance('order-1', peerCursor, now: now); + + expect(await store.cursorFor('order-1'), equals(disputeCursor)); + expect(await peerStore.cursorFor('order-1'), equals(peerCursor)); + expect(prefs.ints.length, equals(2)); + }); + test('clamp is a pure min against the local clock', () { final past = now.subtract(const Duration(seconds: 1)); final future = now.add(const Duration(seconds: 1)); - expect(DisputeChatCursorStore.clamp(past, now), equals(past)); - expect(DisputeChatCursorStore.clamp(future, now), equals(now)); - expect(DisputeChatCursorStore.clamp(now, now), equals(now)); + expect(ChatCursorStore.clamp(past, now), equals(past)); + expect(ChatCursorStore.clamp(future, now), equals(now)); + expect(ChatCursorStore.clamp(now, now), equals(now)); }); }); }