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..e8e8574b 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? adminPubkey, }) { _peer = peer; if (peer != null) { @@ -38,6 +41,9 @@ class Session { peer.publicKey, ); } + if (adminPubkey != null) { + setAdminPeer(adminPubkey); + } } 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,19 @@ class Session { } } + // Parse optional admin pubkey + String? adminPubkey; + final adminPeerValue = json['admin_peer']; + if (adminPeerValue != null) { + if (adminPeerValue is String && adminPeerValue.isNotEmpty) { + adminPubkey = adminPeerValue; + } else if (adminPeerValue is! String) { + throw FormatException( + 'Invalid admin_peer type: ${adminPeerValue.runtimeType}', + ); + } + } + return Session( masterKey: masterKeyValue, tradeKey: tradeKeyValue, @@ -156,6 +176,7 @@ class Session { parentOrderId: parentOrderId, role: role, peer: peer, + adminPubkey: adminPubkey, ); } catch (e) { throw FormatException('Failed to parse Session from JSON: $e'); @@ -164,6 +185,23 @@ 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) { + 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, + 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..b757de4a 100644 --- a/lib/features/disputes/notifiers/dispute_chat_notifier.dart +++ b/lib/features/disputes/notifiers/dispute_chat_notifier.dart @@ -1,15 +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'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -42,10 +37,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 +51,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 +67,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 +80,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 +118,57 @@ 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 wrapperEventId = event.id; + if (wrapperEventId == null) return; + final eventStore = ref.read(eventStorageProvider); + if (await eventStore.hasItem(wrapperEventId)) { 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) - if (isFromAdmin) { - 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})'); - return; - } - - logger.i('Validated message from authorized admin: $senderPubkey'); - } + final senderPubkey = unwrappedEvent.pubkey; + final isFromAdmin = senderPubkey != session.tradeKey.public; + + // 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. - // 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 +193,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,55 +214,31 @@ 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); - + // 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 - // 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})'); - 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?, @@ -324,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); @@ -335,8 +255,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,33 +263,40 @@ 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'); - return; - } - - if (dispute.adminPubkey == null) { - logger.w('Cannot send message: Admin pubkey not found for dispute'); + if (session.adminSharedKey == null) { + logger.w('Cannot send message: Admin shared key not available for dispute: $disputeId'); 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(); + // 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; + 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 { - 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) + // Uses the real rumor ID so relay echo deduplication works correctly final pendingMessage = DisputeChat( id: rumorId, message: text, @@ -384,46 +310,21 @@ 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 - final rumor = NostrEvent.fromPartialData( - keyPairs: session.tradeKey, - content: content, - kind: 1, - tags: [], - ); - - // 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 +334,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 +355,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 +383,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 +392,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 +426,21 @@ 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'); + + logger.w('No session found matching disputeId: $disputeId'); return null; } catch (e, stackTrace) { logger.e('Error getting session for dispute: $e', stackTrace: stackTrace); @@ -568,7 +460,7 @@ final disputeChatNotifierProvider = StateNotifierProvider.family( (ref, disputeId) { final notifier = DisputeChatNotifier(disputeId, ref); - notifier.initialize(); + unawaited(notifier.initialize()); return notifier; }, -); \ No newline at end of file +); 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 diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 75ba0de2..12f92dd0 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -499,6 +499,44 @@ 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) { + 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'); + } + } 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..de42895f --- /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, + adminPubkey: 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), + adminPubkey: 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), + adminPubkey: 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))); + }); + }); +}