From 0f6fd96b6fb3fb8403f259627ff2f926e1d8a7b6 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:59:52 -0600 Subject: [PATCH 1/7] feat: migrate dispute chat to shared key encryption (p2pWrap/p2pUnwrap) - Add adminSharedKey field and setAdminPeer() to Session model with serialization - Add adminTookDispute handler in abstract_mostro_notifier to compute/persist admin shared key - Switch DisputeChatNotifier from mostroWrap/mostroUnWrap to p2pWrap/p2pUnwrap - Route dispute chat subscription via adminSharedKey.public instead of tradeKey.public - Use plain text content instead of MostroMessage JSON wrapper - Remove dm skip logic from MostroService (no longer needed with shared key routing) - Add dispute shared key tests (7 tests) and p2pWrap/p2pUnwrap round-trip tests (4 tests) --- docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md | 2 +- lib/data/models/session.dart | 29 ++ .../notifiers/dispute_chat_notifier.dart | 247 ++++++------------ .../notfiers/abstract_mostro_notifier.dart | 28 ++ lib/services/mostro_service.dart | 6 - test/data/models/nostr_event_wrap_test.dart | 148 +++++++++++ .../disputes/dispute_shared_key_test.dart | 175 +++++++++++++ 7 files changed, 461 insertions(+), 174 deletions(-) create mode 100644 test/data/models/nostr_event_wrap_test.dart create mode 100644 test/features/disputes/dispute_shared_key_test.dart diff --git a/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md b/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md index 8b9e1563..cba2d32d 100644 --- a/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md +++ b/docs/DISPUTE_CHAT_MULTIMEDIA_PLAN.md @@ -23,7 +23,7 @@ Currently the app has two completely different chat mechanisms: --- ## Phase 1: Shared Key for Dispute Chat (Protocol Change) -**Status:** `TODO` +**Status:** `DONE` **PR scope:** Core encryption/protocol change — no UI changes ### What you can test after this phase diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index b3e30e4e..411da450 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -19,6 +19,8 @@ class Session { Role? role; Peer? _peer; NostrKeyPairs? _sharedKey; + String? _adminPubkey; + NostrKeyPairs? _adminSharedKey; Session({ required this.masterKey, @@ -30,6 +32,7 @@ class Session { this.parentOrderId, this.role, Peer? peer, + String? adminPeer, }) { _peer = peer; if (peer != null) { @@ -38,6 +41,9 @@ class Session { peer.publicKey, ); } + if (adminPeer != null) { + setAdminPeer(adminPeer); + } } Map toJson() => { @@ -49,6 +55,7 @@ class Session { 'parent_order_id': parentOrderId, 'role': role?.value, 'peer': peer?.publicKey, + 'admin_peer': _adminPubkey, }; factory Session.fromJson(Map json) { @@ -146,6 +153,15 @@ class Session { } } + // Parse optional admin peer + String? adminPeer; + final adminPeerValue = json['admin_peer']; + if (adminPeerValue != null) { + if (adminPeerValue is String && adminPeerValue.isNotEmpty) { + adminPeer = adminPeerValue; + } + } + return Session( masterKey: masterKeyValue, tradeKey: tradeKeyValue, @@ -156,6 +172,7 @@ class Session { parentOrderId: parentOrderId, role: role, peer: peer, + adminPeer: adminPeer, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); @@ -164,6 +181,18 @@ class Session { NostrKeyPairs? get sharedKey => _sharedKey; + String? get adminPubkey => _adminPubkey; + NostrKeyPairs? get adminSharedKey => _adminSharedKey; + + /// Compute and store the admin shared key via ECDH + void setAdminPeer(String adminPubkey) { + _adminPubkey = adminPubkey; + _adminSharedKey = NostrUtils.computeSharedKey( + tradeKey.private, + adminPubkey, + ); + } + Peer? get peer => _peer; set peer(Peer? newPeer) { diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 6900790c..47cc33f2 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -1,14 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/data/models/dispute_chat.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/models/text_message.dart'; import 'package:mostro_mobile/features/disputes/providers/dispute_providers.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -42,10 +38,11 @@ class DisputeChatState { } /// Notifier for dispute chat messages +/// Uses shared key encryption (p2pWrap/p2pUnwrap) with admin via ECDH class DisputeChatNotifier extends StateNotifier { final String disputeId; final Ref ref; - + StreamSubscription? _subscription; ProviderSubscription? _sessionListener; bool _isInitialized = false; @@ -55,14 +52,14 @@ class DisputeChatNotifier extends StateNotifier { /// Initialize the dispute chat by loading historical messages and subscribing to new events Future initialize() async { if (_isInitialized) return; - + logger.i('Initializing dispute chat for disputeId: $disputeId'); await _loadHistoricalMessages(); await _subscribe(); _isInitialized = true; } - /// Subscribe to new dispute chat messages + /// Subscribe to new dispute chat messages using admin shared key Future _subscribe() async { final session = _getSessionForDispute(); if (session == null) { @@ -71,6 +68,12 @@ class DisputeChatNotifier extends StateNotifier { return; } + if (session.adminSharedKey == null) { + logger.w('Admin shared key not available yet for dispute: $disputeId'); + _listenForSession(); + return; + } + // Cancel existing subscription to prevent leaks and duplicate handlers if (_subscription != null) { logger.i('Cancelling previous subscription for dispute: $disputeId'); @@ -78,40 +81,36 @@ class DisputeChatNotifier extends StateNotifier { _subscription = null; } - // Subscribe to kind 1059 (Gift Wrap) events for dispute messages + // Subscribe to kind 1059 (Gift Wrap) events routed to admin shared key final nostrService = ref.read(nostrServiceProvider); final request = NostrRequest( filters: [ NostrFilter( - kinds: [1059], // Gift Wrap - p: [session.tradeKey.public], // Messages to our tradeKey + kinds: [1059], + p: [session.adminSharedKey!.public], ), ], ); - + _subscription = nostrService.subscribeToEvents(request).listen(_onChatEvent); - logger.i('Subscribed to kind 1059 (Gift Wrap) for dispute: $disputeId'); + logger.i('Subscribed to kind 1059 via admin shared key for dispute: $disputeId'); } - /// Listen for session changes and subscribe when session is ready + /// Listen for session changes and subscribe when admin shared key is ready void _listenForSession() { // Cancel any previous listener to avoid leaks _sessionListener?.close(); _sessionListener = null; - - logger.i('Starting to listen for session list changes for dispute: $disputeId'); + + logger.i('Listening for session changes (admin shared key) for dispute: $disputeId'); // Watch the entire session list for changes _sessionListener = ref.listen>( sessionNotifierProvider, (previous, next) { - logger.i('Session list changed, checking for dispute $disputeId match'); - - // Try to find a session that matches this dispute final session = _getSessionForDispute(); - if (session != null) { - // Found a matching session, cancel listener and subscribe - logger.i('Session found for dispute $disputeId, canceling listener and subscribing'); + if (session != null && session.adminSharedKey != null) { + logger.i('Admin shared key available for dispute $disputeId, subscribing'); _sessionListener?.close(); _sessionListener = null; unawaited(_subscribe()); @@ -120,107 +119,63 @@ class DisputeChatNotifier extends StateNotifier { ); } - /// Handle incoming chat events + /// Handle incoming chat events via p2pUnwrap void _onChatEvent(NostrEvent event) async { try { - // Check for kind 1059 (Gift Wrap) if (event.kind != 1059) { return; } final session = _getSessionForDispute(); - if (session == null) { + if (session == null || session.adminSharedKey == null) { return; } - - // Check if this message belongs to this dispute - final dispute = await ref.read(disputeDetailsProvider(disputeId).future); - if (dispute == null) { + + // Verify p tag matches admin shared key + final pTag = event.tags?.firstWhere( + (tag) => tag.isNotEmpty && tag[0] == 'p', + orElse: () => [], + ) ?? []; + if (pTag.isEmpty || pTag.length < 2 || pTag[1] != session.adminSharedKey!.public) { return; } - - // Unwrap the gift wrap using trade key (following NIP-59) - // The mostroUnWrap will handle the two-layer decryption automatically - final unwrappedEvent = await event.mostroUnWrap(session.tradeKey); - - // Parse the Mostro message from the rumor content - String messageText = ''; - String? senderPubkey; - bool isFromAdmin = false; - - try { - // Content can be in two formats: - // 1. CLI format: [{"dm": {"version": 1, "action": "send-dm", "payload": {"text_message": "..."}}}, null] - // 2. Old format: [{"order": {...}}, null] or [{"version": 1, "action": "send-dm", ...}, null] - final contentData = jsonDecode(unwrappedEvent.content ?? '[]'); - if (contentData is List && contentData.isNotEmpty) { - final messageData = contentData[0]; - - // Check if it's the CLI format with Message enum (has "dm" key) - if (messageData is Map && messageData.containsKey('dm')) { - final dmData = messageData['dm']; - if (dmData is Map && dmData.containsKey('payload')) { - final payload = dmData['payload']; - if (payload is Map && payload.containsKey('text_message')) { - messageText = payload['text_message'] as String; - senderPubkey = unwrappedEvent.pubkey; - isFromAdmin = senderPubkey != session.tradeKey.public; - } - } - } else { - // Try parsing as old MostroMessage format - final mostroMessage = MostroMessage.fromJson(messageData); - - // Only process send-dm actions - if (mostroMessage.action != Action.sendDm) { - return; - } - - // Extract text from TextMessage payload - if (mostroMessage.payload != null) { - final textPayload = mostroMessage.getPayload(); - if (textPayload != null) { - messageText = textPayload.message; - senderPubkey = unwrappedEvent.pubkey; - isFromAdmin = senderPubkey != session.tradeKey.public; - } - } - } - } - } catch (e) { - logger.w('Failed to parse Mostro message: $e'); + + // Check for duplicate events + final eventStore = ref.read(eventStorageProvider); + if (await eventStore.hasItem(event.id!)) { return; } + // Unwrap using admin shared key (1-layer p2p decryption) + final unwrappedEvent = await event.p2pUnwrap(session.adminSharedKey!); + + // Content is plain text (no MostroMessage JSON wrapper) + final messageText = unwrappedEvent.content ?? ''; if (messageText.isEmpty) { logger.w('Received empty message, skipping'); return; } - // SECURITY: Validate sender pubkey - // Only accept messages from: - // 1. The user themselves (session.tradeKey.public) - // 2. The assigned admin/solver (dispute.adminPubkey) + final senderPubkey = unwrappedEvent.pubkey; + final isFromAdmin = senderPubkey != session.tradeKey.public; + + // SECURITY: Validate sender pubkey for admin messages if (isFromAdmin) { - if (dispute.adminPubkey == null) { + final dispute = await ref.read(disputeDetailsProvider(disputeId).future); + if (dispute?.adminPubkey == null) { logger.w('Rejecting message: No admin assigned yet for dispute $disputeId'); return; } - - if (senderPubkey != dispute.adminPubkey) { - logger.w('SECURITY: Rejecting message from unauthorized pubkey: $senderPubkey (expected admin: ${dispute.adminPubkey})'); + if (senderPubkey != dispute!.adminPubkey) { + logger.w('SECURITY: Rejecting message from unauthorized pubkey: $senderPubkey'); return; } - - logger.i('Validated message from authorized admin: $senderPubkey'); } - // Generate event ID if not present (can happen with admin messages) final eventId = unwrappedEvent.id ?? event.id ?? 'chat_${DateTime.now().millisecondsSinceEpoch}_${messageText.hashCode}'; final eventTimestamp = unwrappedEvent.createdAt ?? DateTime.now(); - + // Store the event - final eventStore = ref.read(eventStorageProvider); await eventStore.putItem( eventId, { @@ -245,13 +200,13 @@ class DisputeChatNotifier extends StateNotifier { timestamp: eventTimestamp, isFromUser: !isFromAdmin, adminPubkey: isFromAdmin ? senderPubkey : null, - isPending: false, // Received messages are already confirmed + isPending: false, ); final allMessages = [...state.messages, disputeChat]; final deduped = {for (var m in allMessages) m.id: m}.values.toList(); deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - + state = state.copyWith(messages: deduped); logger.i('Added dispute chat message for dispute: $disputeId (from ${isFromAdmin ? "admin" : "user"})'); } catch (e, stackTrace) { @@ -266,48 +221,41 @@ class DisputeChatNotifier extends StateNotifier { state = state.copyWith(isLoading: true); final eventStore = ref.read(eventStorageProvider); - + // Find all dispute chat events for this dispute final chatEvents = await eventStore.find( filter: Filter.and([ eventStore.eq('type', 'dispute_chat'), eventStore.eq('dispute_id', disputeId), ]), - sort: [SortOrder('created_at', true)], // Oldest first + sort: [SortOrder('created_at', true)], ); logger.i('Found ${chatEvents.length} historical messages for dispute: $disputeId'); - // Get dispute to validate admin pubkey final dispute = await ref.read(disputeDetailsProvider(disputeId).future); final List messages = []; int filteredCount = 0; - + for (final eventData in chatEvents) { try { final isFromUser = eventData['is_from_user'] as bool? ?? true; final messagePubkey = eventData['pubkey'] as String?; - + // SECURITY: Filter messages by authorized pubkeys - // Only include messages from: - // 1. The user themselves (is_from_user = true) - // 2. The assigned admin/solver (matches dispute.adminPubkey) if (!isFromUser) { - // Message is from admin, validate pubkey if (dispute?.adminPubkey == null) { - logger.w('Filtering historical message: No admin assigned yet'); filteredCount++; continue; } - if (messagePubkey != null && messagePubkey != dispute!.adminPubkey) { - logger.w('SECURITY: Filtering historical message from unauthorized pubkey: $messagePubkey (expected: ${dispute.adminPubkey})'); + logger.w('SECURITY: Filtering historical message from unauthorized pubkey: $messagePubkey'); filteredCount++; continue; } } - + messages.add(DisputeChat( id: eventData['id'] as String, message: eventData['content'] as String? ?? '', @@ -335,8 +283,7 @@ class DisputeChatNotifier extends StateNotifier { } } - /// Send a message in the dispute chat - /// Uses Gift Wrap (NIP-59) with MostroMessage format like mostro-cli + /// Send a message in the dispute chat using p2pWrap with admin shared key Future sendMessage(String text) async { final session = _getSessionForDispute(); if (session == null) { @@ -344,32 +291,23 @@ class DisputeChatNotifier extends StateNotifier { return; } - // Get dispute to find admin pubkey and orderId - final dispute = await ref.read(disputeDetailsProvider(disputeId).future); - if (dispute == null) { - logger.w('Cannot send message: Dispute not found'); + if (session.adminSharedKey == null) { + logger.w('Cannot send message: Admin shared key not available for dispute: $disputeId'); return; } - - if (dispute.adminPubkey == null) { - logger.w('Cannot send message: Admin pubkey not found for dispute'); - return; - } - - // Get orderId from session + final orderId = session.orderId; if (orderId == null) { logger.w('Cannot send message: Session orderId is null'); return; } - // Generate ID for message final rumorId = 'rumor_${DateTime.now().millisecondsSinceEpoch}'; final rumorTimestamp = DateTime.now(); try { - logger.i('Sending Gift Wrap DM to admin: ${dispute.adminPubkey}'); - + logger.i('Sending p2pWrap DM to admin via shared key for dispute: $disputeId'); + // Add message to state with isPending=true (optimistic UI) final pendingMessage = DisputeChat( id: rumorId, @@ -384,46 +322,31 @@ class DisputeChatNotifier extends StateNotifier { deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp)); state = state.copyWith(messages: deduped, error: null); - // For dispute chat, the CLI expects format matching Message enum from mostro-core - // Message::Dm(MessageKind) where MessageKind has version, action, and payload - // Note: Rust uses snake_case for enum variants in JSON serialization - final content = jsonEncode([ - { - "dm": { - "version": 1, - "action": "send-dm", - "payload": { - "text_message": text - } - } - }, - null - ]); - - // Create rumor (kind 1) with the serialized content + // Create rumor (kind 1) with plain text content final rumor = NostrEvent.fromPartialData( keyPairs: session.tradeKey, - content: content, + content: text, kind: 1, - tags: [], + tags: [ + ["p", session.adminSharedKey!.public], + ], ); - // Wrap the rumor using the new mostroWrap method (creates SEAL + Gift Wrap) - final wrappedEvent = await rumor.mostroWrap( + // Wrap using p2pWrap (1-layer, shared key routing) + final wrappedEvent = await rumor.p2pWrap( session.tradeKey, - dispute.adminPubkey!, + session.adminSharedKey!.public, ); - logger.i('Sending gift wrap from ${session.tradeKey.public} to ${dispute.adminPubkey}'); + logger.i('Sending p2pWrap from ${session.tradeKey.public} via shared key ${session.adminSharedKey!.public}'); - // Publish to network - await to catch network/initialization errors + // Publish to network try { await ref.read(nostrServiceProvider).publishEvent(wrappedEvent); - logger.i('Dispute message sent successfully to admin for dispute: $disputeId'); + logger.i('Dispute message sent successfully for dispute: $disputeId'); } catch (publishError, publishStack) { logger.e('Failed to publish dispute message: $publishError', stackTrace: publishStack); - - // Mark message as failed + final failedMessage = pendingMessage.copyWith( isPending: false, error: 'Failed to publish: $publishError', @@ -433,8 +356,7 @@ class DisputeChatNotifier extends StateNotifier { messages: updatedMessages, error: 'Failed to send message: $publishError', ); - - // Store failed state + final eventStore = ref.read(eventStorageProvider); try { await eventStore.putItem( @@ -455,7 +377,7 @@ class DisputeChatNotifier extends StateNotifier { } catch (storageError) { logger.e('Failed to store error state: $storageError'); } - return; // Exit early, don't mark as success + return; } // Update message to isPending=false (success) @@ -483,8 +405,7 @@ class DisputeChatNotifier extends StateNotifier { ); } catch (e, stackTrace) { logger.e('Failed to send dispute message: $e', stackTrace: stackTrace); - - // Mark message as failed in state + final failedMessage = state.messages .firstWhere((m) => m.id == rumorId, orElse: () => DisputeChat( id: rumorId, @@ -493,14 +414,13 @@ class DisputeChatNotifier extends StateNotifier { isFromUser: true, )) .copyWith(isPending: false, error: e.toString()); - + final updatedMessages = state.messages.map((m) => m.id == rumorId ? failedMessage : m).toList(); state = state.copyWith( messages: updatedMessages, error: 'Failed to send message: $e', ); - // Store failed state in local storage final eventStore = ref.read(eventStorageProvider); try { await eventStore.putItem( @@ -528,27 +448,20 @@ class DisputeChatNotifier extends StateNotifier { Session? _getSessionForDispute() { try { final sessions = ref.read(sessionNotifierProvider); - logger.i('Looking for session for dispute: $disputeId, available sessions: ${sessions.length}'); - - // Search through all sessions to find the one that has this dispute + for (final session in sessions) { if (session.orderId != null) { try { final orderState = ref.read(orderNotifierProvider(session.orderId!)); - - // Check if this order state contains our dispute if (orderState.dispute?.disputeId == disputeId) { - logger.i('Found session for dispute: $disputeId with orderId: ${session.orderId}'); return session; } } catch (e) { - // Continue checking other sessions continue; } } } - - logger.w('No session found for dispute: $disputeId'); + return null; } catch (e, stackTrace) { logger.e('Error getting session for dispute: $e', stackTrace: stackTrace); @@ -571,4 +484,4 @@ final disputeChatNotifierProvider = notifier.initialize(); return notifier; }, -); \ No newline at end of file +); diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 75ba0de2..0d291012 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -499,6 +499,34 @@ class AbstractMostroNotifier extends StateNotifier { break; + case Action.adminTookDispute: + // Compute admin shared key and persist on session + String? adminPubkey; + if (event.payload is Peer) { + final peerPayload = event.getPayload(); + if (peerPayload != null && peerPayload.publicKey.isNotEmpty) { + adminPubkey = peerPayload.publicKey; + } + } + // Fallback: check dispute in state + adminPubkey ??= state.dispute?.adminPubkey; + + if (adminPubkey != null && adminPubkey.isNotEmpty) { + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + await sessionNotifier.updateSession( + orderId, (s) => s.setAdminPeer(adminPubkey!)); + logger.i( + 'Admin shared key computed and persisted for order $orderId'); + } else { + logger.w( + 'adminTookDispute: Could not extract admin pubkey for order $orderId'); + } + + if (isRecent && !bypassTimestampGate) { + navProvider.go('/trade_detail/$orderId'); + } + break; + case Action.adminSettled: if (isRecent && !bypassTimestampGate) { navProvider.go('/trade_detail/$orderId'); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 5233ecbd..67baf158 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -129,12 +129,6 @@ class MostroService { return; } - // Skip dispute chat messages (they have "dm" key and are handled by DisputeChatNotifier) - if (result[0] is Map && (result[0] as Map).containsKey('dm')) { - logger.i('Skipping dispute chat message (handled by DisputeChatNotifier)'); - return; - } - // Skip restore-specific payloads that arrive as historical events due to temporary subscription if (result[0] is Map && _isRestorePayload(result[0] as Map)) { return; diff --git a/test/data/models/nostr_event_wrap_test.dart b/test/data/models/nostr_event_wrap_test.dart new file mode 100644 index 00000000..83dc2b5c --- /dev/null +++ b/test/data/models/nostr_event_wrap_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +void main() { + // Use valid keys from NIP-06 test vectors + const validMnemonic = + 'leader monkey parrot ring guide accident before fence cannon height naive bean'; + final keyDerivator = KeyDerivator(Config.keyDerivationPath); + final extendedPrivKey = keyDerivator.extendedKeyFromMnemonic(validMnemonic); + final senderPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 1); + final receiverPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 2); + final receiverPublicKey = keyDerivator.privateToPublicKey(receiverPrivKey); + 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 { + // Compute shared key from both sides + final senderSharedKey = + NostrUtils.computeSharedKey(senderPrivKey, receiverPublicKey); + final receiverSharedKey = + NostrUtils.computeSharedKey(receiverPrivKey, senderPublicKey); + + // Both should be the same + expect(senderSharedKey.public, equals(receiverSharedKey.public)); + + // Create inner event (kind 1) + final innerEvent = NostrEvent.fromPartialData( + keyPairs: NostrKeyPairs(private: senderPrivKey), + content: 'Hello admin, I need help with my dispute', + kind: 1, + tags: [ + ["p", senderSharedKey.public], + ], + ); + + // Wrap with p2pWrap + final wrappedEvent = await innerEvent.p2pWrap( + NostrKeyPairs(private: senderPrivKey), + senderSharedKey.public, + ); + + // Unwrap with receiver's shared key + final unwrapped = await wrappedEvent.p2pUnwrap(receiverSharedKey); + + // Verify content matches + expect(unwrapped.content, + equals('Hello admin, I need help with my dispute')); + // Verify sender pubkey matches + expect(unwrapped.pubkey, equals(senderPublicKey)); + // Verify kind 1 + expect(unwrapped.kind, equals(1)); + }); + + test('unwrap fails with wrong key', () async { + final sharedKey = + NostrUtils.computeSharedKey(senderPrivKey, receiverPublicKey); + final wrongSharedKey = + NostrUtils.computeSharedKey(wrongPrivKey, receiverPublicKey); + + final innerEvent = NostrEvent.fromPartialData( + keyPairs: NostrKeyPairs(private: senderPrivKey), + content: 'Secret message', + kind: 1, + tags: [ + ["p", sharedKey.public], + ], + ); + + final wrappedEvent = await innerEvent.p2pWrap( + NostrKeyPairs(private: senderPrivKey), + sharedKey.public, + ); + + // Unwrap with wrong key should throw + expect( + () => wrappedEvent.p2pUnwrap(wrongSharedKey), + throwsA(isA()), + ); + }); + + 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); + final receiverSharedKey = + NostrUtils.computeSharedKey(receiverPrivKey, senderPublicKey); + + // Content is plain text (dispute chat style) + const plainText = 'This is a dispute message with no JSON wrapping'; + + final innerEvent = NostrEvent.fromPartialData( + keyPairs: NostrKeyPairs(private: senderPrivKey), + content: plainText, + kind: 1, + tags: [ + ["p", sharedKey.public], + ], + ); + + final wrapped = await innerEvent.p2pWrap( + NostrKeyPairs(private: senderPrivKey), + sharedKey.public, + ); + + final unwrapped = await wrapped.p2pUnwrap(receiverSharedKey); + + expect(unwrapped.content, equals(plainText)); + }); + }); +} diff --git a/test/features/disputes/dispute_shared_key_test.dart b/test/features/disputes/dispute_shared_key_test.dart new file mode 100644 index 00000000..1fc36ea3 --- /dev/null +++ b/test/features/disputes/dispute_shared_key_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +void main() { + // Use valid keys from NIP-06 test vectors + const validMnemonic = + 'leader monkey parrot ring guide accident before fence cannon height naive bean'; + final keyDerivator = KeyDerivator(Config.keyDerivationPath); + final extendedPrivKey = keyDerivator.extendedKeyFromMnemonic(validMnemonic); + final masterPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 0); + final tradePrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 1); + final peerPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 2); + final peerPublicKey = keyDerivator.privateToPublicKey(peerPrivKey); + final adminPrivKey = keyDerivator.derivePrivateKey(extendedPrivKey, 3); + final adminPublicKey = keyDerivator.privateToPublicKey(adminPrivKey); + final tradePublicKey = keyDerivator.privateToPublicKey(tradePrivKey); + + group('Dispute Shared Key Computation', () { + test('computes identical shared key from both sides (user and admin)', () { + // User side: ECDH(tradeKey.private, adminPubkey) + final userSideKey = + NostrUtils.computeSharedKey(tradePrivKey, adminPublicKey); + + // Admin side: ECDH(adminKey.private, tradeKey.public) + final adminSideKey = + NostrUtils.computeSharedKey(adminPrivKey, tradePublicKey); + + // Both should produce the same shared secret + expect(userSideKey.private, equals(adminSideKey.private)); + expect(userSideKey.public, equals(adminSideKey.public)); + }); + + test('admin shared key is independent from peer shared key', () { + final session = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order', + role: Role.buyer, + peer: Peer(publicKey: peerPublicKey), + ); + + // Set admin peer + session.setAdminPeer(adminPublicKey); + + // Both keys should exist + expect(session.sharedKey, isNotNull); + expect(session.adminSharedKey, isNotNull); + + // Keys should be different + expect( + session.sharedKey!.private, isNot(equals(session.adminSharedKey!.private))); + expect( + session.sharedKey!.public, isNot(equals(session.adminSharedKey!.public))); + }); + + test('admin shared key is null when no admin assigned', () { + final session = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order', + role: Role.buyer, + ); + + expect(session.adminSharedKey, isNull); + expect(session.adminPubkey, isNull); + }); + + test('setAdminPeer computes and stores admin shared key', () { + final session = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order', + role: Role.buyer, + ); + + expect(session.adminSharedKey, isNull); + + session.setAdminPeer(adminPublicKey); + + expect(session.adminSharedKey, isNotNull); + expect(session.adminPubkey, equals(adminPublicKey)); + + // Verify the computed key matches manual ECDH computation + final expectedKey = + NostrUtils.computeSharedKey(tradePrivKey, adminPublicKey); + expect(session.adminSharedKey!.private, equals(expectedKey.private)); + expect(session.adminSharedKey!.public, equals(expectedKey.public)); + }); + + test('constructor computes admin shared key when adminPeer is provided', () { + final session = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order', + role: Role.buyer, + adminPeer: adminPublicKey, + ); + + expect(session.adminSharedKey, isNotNull); + expect(session.adminPubkey, equals(adminPublicKey)); + }); + + test('toJson includes admin_peer and fromJson restores it', () { + final session = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order', + role: Role.buyer, + peer: Peer(publicKey: peerPublicKey), + adminPeer: adminPublicKey, + ); + + final json = session.toJson(); + expect(json['admin_peer'], equals(adminPublicKey)); + + // Reconstruct from JSON (fromJson needs key pair objects, not just public keys) + // So we test the serialized value is present and correct + final restoredSession = Session.fromJson({ + ...json, + 'master_key': NostrKeyPairs(private: masterPrivKey), + 'trade_key': NostrKeyPairs(private: tradePrivKey), + }); + + expect(restoredSession.adminPubkey, equals(adminPublicKey)); + expect(restoredSession.adminSharedKey, isNotNull); + expect(restoredSession.adminSharedKey!.private, + equals(session.adminSharedKey!.private)); + }); + + test('session can have both peer and admin simultaneously', () { + final session = Session( + masterKey: NostrKeyPairs(private: masterPrivKey), + tradeKey: NostrKeyPairs(private: tradePrivKey), + keyIndex: 1, + fullPrivacy: false, + startTime: DateTime.now(), + orderId: 'test-order', + role: Role.buyer, + peer: Peer(publicKey: peerPublicKey), + adminPeer: adminPublicKey, + ); + + // Both shared keys exist + expect(session.sharedKey, isNotNull); + expect(session.adminSharedKey, isNotNull); + expect(session.peer, isNotNull); + expect(session.adminPubkey, isNotNull); + + // They are independent + expect(session.sharedKey!.public, + isNot(equals(session.adminSharedKey!.public))); + }); + }); +} From bd77986ca87d7eaf3e03fce535696626c716f6e8 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:27:00 -0600 Subject: [PATCH 2/7] coderabbit suggestions --- lib/data/models/session.dart | 4 ++++ lib/features/disputes/notifiers/dispute_chat_notifier.dart | 2 +- lib/features/order/notfiers/abstract_mostro_notifier.dart | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 411da450..b79a20df 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -159,6 +159,10 @@ class Session { if (adminPeerValue != null) { if (adminPeerValue is String && adminPeerValue.isNotEmpty) { adminPeer = adminPeerValue; + } else if (adminPeerValue is! String) { + throw FormatException( + 'Invalid admin_peer type: ${adminPeerValue.runtimeType}', + ); } } diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 47cc33f2..6f993f7a 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -481,7 +481,7 @@ final disputeChatNotifierProvider = StateNotifierProvider.family( (ref, disputeId) { final notifier = DisputeChatNotifier(disputeId, ref); - notifier.initialize(); + unawaited(notifier.initialize()); return notifier; }, ); diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 0d291012..a24f5bc9 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -515,6 +515,11 @@ class AbstractMostroNotifier extends StateNotifier { final sessionNotifier = ref.read(sessionNotifierProvider.notifier); await sessionNotifier.updateSession( orderId, (s) => s.setAdminPeer(adminPubkey!)); + // Re-fetch session to reflect the updated adminSharedKey + final refreshed = sessionNotifier.getSessionByOrderId(orderId); + if (refreshed != null) { + session = refreshed; + } logger.i( 'Admin shared key computed and persisted for order $orderId'); } else { From e619f879d69f9c4f9b35d55e9fe1e9a2dabdd8b0 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:13:14 -0600 Subject: [PATCH 3/7] fix: address review feedback on dispute shared key - Rename adminPeer param to adminPubkey for naming consistency - Add validation guard in setAdminPeer() for empty/invalid pubkeys - Re-fetch session after updateSession in adminTookDispute handler - Wrap initialize() with unawaited() in dispute chat provider - Improve admin message rejection logs with eventId context - Add type validation for admin_peer in Session.fromJson --- lib/data/models/session.dart | 19 ++++++++++++------- .../notifiers/dispute_chat_notifier.dart | 8 ++++++-- .../disputes/dispute_shared_key_test.dart | 6 +++--- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index b79a20df..e8e8574b 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -32,7 +32,7 @@ class Session { this.parentOrderId, this.role, Peer? peer, - String? adminPeer, + String? adminPubkey, }) { _peer = peer; if (peer != null) { @@ -41,8 +41,8 @@ class Session { peer.publicKey, ); } - if (adminPeer != null) { - setAdminPeer(adminPeer); + if (adminPubkey != null) { + setAdminPeer(adminPubkey); } } @@ -153,12 +153,12 @@ class Session { } } - // Parse optional admin peer - String? adminPeer; + // Parse optional admin pubkey + String? adminPubkey; final adminPeerValue = json['admin_peer']; if (adminPeerValue != null) { if (adminPeerValue is String && adminPeerValue.isNotEmpty) { - adminPeer = adminPeerValue; + adminPubkey = adminPeerValue; } else if (adminPeerValue is! String) { throw FormatException( 'Invalid admin_peer type: ${adminPeerValue.runtimeType}', @@ -176,7 +176,7 @@ class Session { parentOrderId: parentOrderId, role: role, peer: peer, - adminPeer: adminPeer, + adminPubkey: adminPubkey, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); @@ -190,6 +190,11 @@ class Session { /// Compute and store the admin shared key via ECDH void setAdminPeer(String adminPubkey) { + if (adminPubkey.isEmpty || adminPubkey.length != 64) { + throw ArgumentError( + 'Invalid admin pubkey: expected 64-char hex, got ${adminPubkey.length} chars', + ); + } _adminPubkey = adminPubkey; _adminSharedKey = NostrUtils.computeSharedKey( tradeKey.private, diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 6f993f7a..8182e81f 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -163,11 +163,15 @@ class DisputeChatNotifier extends StateNotifier { if (isFromAdmin) { final dispute = await ref.read(disputeDetailsProvider(disputeId).future); if (dispute?.adminPubkey == null) { - logger.w('Rejecting message: No admin assigned yet for dispute $disputeId'); + logger.w('Rejecting admin message for dispute $disputeId: ' + 'adminPubkey not yet available (possible race with adminTookDispute). ' + 'eventId=${event.id}, sender=$senderPubkey'); return; } if (senderPubkey != dispute!.adminPubkey) { - logger.w('SECURITY: Rejecting message from unauthorized pubkey: $senderPubkey'); + logger.w('SECURITY: Rejecting message from unauthorized pubkey: ' + '$senderPubkey (expected: ${dispute.adminPubkey}), ' + 'eventId=${event.id}, dispute=$disputeId'); return; } } diff --git a/test/features/disputes/dispute_shared_key_test.dart b/test/features/disputes/dispute_shared_key_test.dart index 1fc36ea3..de42895f 100644 --- a/test/features/disputes/dispute_shared_key_test.dart +++ b/test/features/disputes/dispute_shared_key_test.dart @@ -111,7 +111,7 @@ void main() { startTime: DateTime.now(), orderId: 'test-order', role: Role.buyer, - adminPeer: adminPublicKey, + adminPubkey: adminPublicKey, ); expect(session.adminSharedKey, isNotNull); @@ -128,7 +128,7 @@ void main() { orderId: 'test-order', role: Role.buyer, peer: Peer(publicKey: peerPublicKey), - adminPeer: adminPublicKey, + adminPubkey: adminPublicKey, ); final json = session.toJson(); @@ -158,7 +158,7 @@ void main() { orderId: 'test-order', role: Role.buyer, peer: Peer(publicKey: peerPublicKey), - adminPeer: adminPublicKey, + adminPubkey: adminPublicKey, ); // Both shared keys exist From 7b611c1804e9683a1e2883a4a63d94b49d46d356 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:46:56 -0600 Subject: [PATCH 4/7] fix: use real rumor event ID for optimistic message to prevent duplicate display --- .../notifiers/dispute_chat_notifier.dart | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 8182e81f..9010beb2 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -306,13 +306,24 @@ class DisputeChatNotifier extends StateNotifier { return; } - final rumorId = 'rumor_${DateTime.now().millisecondsSinceEpoch}'; - final rumorTimestamp = DateTime.now(); + // Create rumor (kind 1) with plain text content FIRST to get real event ID + final rumor = NostrEvent.fromPartialData( + keyPairs: session.tradeKey, + content: text, + kind: 1, + tags: [ + ["p", session.adminSharedKey!.public], + ], + ); + + final rumorId = rumor.id!; + final rumorTimestamp = rumor.createdAt ?? DateTime.now(); try { logger.i('Sending p2pWrap DM to admin via shared key for dispute: $disputeId'); // Add message to state with isPending=true (optimistic UI) + // Uses the real rumor ID so relay echo deduplication works correctly final pendingMessage = DisputeChat( id: rumorId, message: text, @@ -326,16 +337,6 @@ class DisputeChatNotifier extends StateNotifier { deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp)); state = state.copyWith(messages: deduped, error: null); - // Create rumor (kind 1) with plain text content - final rumor = NostrEvent.fromPartialData( - keyPairs: session.tradeKey, - content: text, - kind: 1, - tags: [ - ["p", session.adminSharedKey!.public], - ], - ); - // Wrap using p2pWrap (1-layer, shared key routing) final wrappedEvent = await rumor.p2pWrap( session.tradeKey, From 43991c9778d71f7e5b6a024b0a6409eba0feb922 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:04:52 -0600 Subject: [PATCH 5/7] coderabbit suggetsions --- .../disputes/notifiers/dispute_chat_notifier.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 9010beb2..2d6cfbd2 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -141,8 +141,10 @@ class DisputeChatNotifier extends StateNotifier { } // Check for duplicate events + final wrapperEventId = event.id; + if (wrapperEventId == null) return; final eventStore = ref.read(eventStorageProvider); - if (await eventStore.hasItem(event.id!)) { + if (await eventStore.hasItem(wrapperEventId)) { return; } @@ -253,7 +255,7 @@ class DisputeChatNotifier extends StateNotifier { filteredCount++; continue; } - if (messagePubkey != null && messagePubkey != dispute!.adminPubkey) { + if (messagePubkey == null || messagePubkey != dispute!.adminPubkey) { logger.w('SECURITY: Filtering historical message from unauthorized pubkey: $messagePubkey'); filteredCount++; continue; @@ -316,7 +318,12 @@ class DisputeChatNotifier extends StateNotifier { ], ); - final rumorId = rumor.id!; + final rumorId = rumor.id; + if (rumorId == null) { + logger.e('Failed to compute rumor ID for dispute: $disputeId'); + state = state.copyWith(error: 'Failed to prepare message'); + return; + } final rumorTimestamp = rumor.createdAt ?? DateTime.now(); try { From 7a8a8093bd24c9faddb25259b445d0e775480bd7 Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:15:36 -0600 Subject: [PATCH 6/7] ux: auto-scroll dispute chat to bottom on new messages when near end --- .../widgets/dispute_messages_list.dart | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/lib/features/disputes/widgets/dispute_messages_list.dart b/lib/features/disputes/widgets/dispute_messages_list.dart index 47c90c31..e3ce9ab9 100644 --- a/lib/features/disputes/widgets/dispute_messages_list.dart +++ b/lib/features/disputes/widgets/dispute_messages_list.dart @@ -33,12 +33,16 @@ class DisputeMessagesList extends ConsumerStatefulWidget { class _DisputeMessagesListState extends ConsumerState { late ScrollController _scrollController; + int _previousMessageCount = 0; + + /// Threshold in pixels to consider the user "near the bottom" + static const _nearBottomThreshold = 150.0; @override void initState() { super.initState(); _scrollController = widget.scrollController ?? ScrollController(); - + // Scroll to bottom on first load WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(animate: false); @@ -53,17 +57,27 @@ class _DisputeMessagesListState extends ConsumerState { super.dispose(); } + bool _isNearBottom() { + if (!_scrollController.hasClients) return true; + final position = _scrollController.position; + return position.maxScrollExtent - position.pixels <= _nearBottomThreshold; + } + void _scrollToBottom({bool animate = true}) { if (!_scrollController.hasClients) return; - - if (animate) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } else { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + + try { + if (animate) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } else { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + } catch (_) { + // Silently handle scroll errors (e.g. disposed controller) } } @@ -91,6 +105,16 @@ class _DisputeMessagesListState extends ConsumerState { final messages = chatState.messages; + // Auto-scroll when new messages arrive and user is near the bottom + if (messages.length > _previousMessageCount && _previousMessageCount > 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isNearBottom()) { + _scrollToBottom(); + } + }); + } + _previousMessageCount = messages.length; + return Container( color: AppTheme.backgroundDark, child: messages.isEmpty From f18c0405f34381aadafffe31cfab3f2319010c5d Mon Sep 17 00:00:00 2001 From: Catrya <140891948+Catrya@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:18:57 -0600 Subject: [PATCH 7/7] fix: resolve dispute chat race condition and harden admin key handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant disputeDetailsProvider check — ECDH is sufficient auth - Wrap setAdminPeer() in try/catch to handle malformed admin pubkeys - Add diagnostic logging when session lookup fails for dispute --- .../notifiers/dispute_chat_notifier.dart | 49 +++---------------- .../notfiers/abstract_mostro_notifier.dart | 23 +++++---- 2 files changed, 22 insertions(+), 50 deletions(-) diff --git a/lib/features/disputes/notifiers/dispute_chat_notifier.dart b/lib/features/disputes/notifiers/dispute_chat_notifier.dart index 2d6cfbd2..b757de4a 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -5,7 +5,6 @@ import 'package:mostro_mobile/services/logger_service.dart'; import 'package:mostro_mobile/data/models/dispute_chat.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/features/disputes/providers/dispute_providers.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -161,22 +160,10 @@ class DisputeChatNotifier extends StateNotifier { final senderPubkey = unwrappedEvent.pubkey; final isFromAdmin = senderPubkey != session.tradeKey.public; - // SECURITY: Validate sender pubkey for admin messages - if (isFromAdmin) { - final dispute = await ref.read(disputeDetailsProvider(disputeId).future); - if (dispute?.adminPubkey == null) { - logger.w('Rejecting admin message for dispute $disputeId: ' - 'adminPubkey not yet available (possible race with adminTookDispute). ' - 'eventId=${event.id}, sender=$senderPubkey'); - return; - } - if (senderPubkey != dispute!.adminPubkey) { - logger.w('SECURITY: Rejecting message from unauthorized pubkey: ' - '$senderPubkey (expected: ${dispute.adminPubkey}), ' - 'eventId=${event.id}, dispute=$disputeId'); - return; - } - } + // SECURITY: The ECDH shared key IS the authentication. + // If p2pUnwrap succeeded, the sender holds the admin's private key. + // No additional pubkey verification needed — it would introduce a race + // condition with disputeDetailsProvider resolution. final eventId = unwrappedEvent.id ?? event.id ?? 'chat_${DateTime.now().millisecondsSinceEpoch}_${messageText.hashCode}'; final eventTimestamp = unwrappedEvent.createdAt ?? DateTime.now(); @@ -239,36 +226,19 @@ class DisputeChatNotifier extends StateNotifier { logger.i('Found ${chatEvents.length} historical messages for dispute: $disputeId'); - final dispute = await ref.read(disputeDetailsProvider(disputeId).future); - + // SECURITY: Historical messages were already authenticated via ECDH + // when they were received and stored. No additional pubkey filtering needed. final List messages = []; - int filteredCount = 0; for (final eventData in chatEvents) { try { - final isFromUser = eventData['is_from_user'] as bool? ?? true; - final messagePubkey = eventData['pubkey'] as String?; - - // SECURITY: Filter messages by authorized pubkeys - if (!isFromUser) { - if (dispute?.adminPubkey == null) { - filteredCount++; - continue; - } - if (messagePubkey == null || messagePubkey != dispute!.adminPubkey) { - logger.w('SECURITY: Filtering historical message from unauthorized pubkey: $messagePubkey'); - filteredCount++; - continue; - } - } - messages.add(DisputeChat( id: eventData['id'] as String, message: eventData['content'] as String? ?? '', timestamp: DateTime.fromMillisecondsSinceEpoch( (eventData['created_at'] as int) * 1000, ), - isFromUser: isFromUser, + isFromUser: eventData['is_from_user'] as bool? ?? true, adminPubkey: eventData['admin_pubkey'] as String?, isPending: eventData['isPending'] as bool? ?? false, error: eventData['error'] as String?, @@ -278,10 +248,6 @@ class DisputeChatNotifier extends StateNotifier { } } - if (filteredCount > 0) { - logger.i('Filtered $filteredCount unauthorized messages from dispute $disputeId'); - } - state = state.copyWith(messages: messages, isLoading: false); } catch (e, stackTrace) { logger.e('Error loading historical messages: $e', stackTrace: stackTrace); @@ -474,6 +440,7 @@ class DisputeChatNotifier extends StateNotifier { } } + logger.w('No session found matching disputeId: $disputeId'); return null; } catch (e, stackTrace) { logger.e('Error getting session for dispute: $e', stackTrace: stackTrace); diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index a24f5bc9..12f92dd0 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -512,16 +512,21 @@ class AbstractMostroNotifier extends StateNotifier { adminPubkey ??= state.dispute?.adminPubkey; if (adminPubkey != null && adminPubkey.isNotEmpty) { - final sessionNotifier = ref.read(sessionNotifierProvider.notifier); - await sessionNotifier.updateSession( - orderId, (s) => s.setAdminPeer(adminPubkey!)); - // Re-fetch session to reflect the updated adminSharedKey - final refreshed = sessionNotifier.getSessionByOrderId(orderId); - if (refreshed != null) { - session = refreshed; + try { + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); + await sessionNotifier.updateSession( + orderId, (s) => s.setAdminPeer(adminPubkey!)); + // Re-fetch session to reflect the updated adminSharedKey + final refreshed = sessionNotifier.getSessionByOrderId(orderId); + if (refreshed != null) { + session = refreshed; + } + logger.i( + 'Admin shared key computed and persisted for order $orderId'); + } catch (e) { + logger.e( + 'adminTookDispute: Failed to set admin peer for order $orderId: $e'); } - logger.i( - 'Admin shared key computed and persisted for order $orderId'); } else { logger.w( 'adminTookDispute: Could not extract admin pubkey for order $orderId');