diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 4780a173..ed786f5e 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -1,4 +1,7 @@ +import 'package:nip44/nip44.dart'; +import 'dart:isolate'; import 'dart:convert'; +import 'dart:typed_data'; import 'dart:math'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/range_amount.dart'; @@ -483,14 +486,45 @@ extension NostrEventExtensions on NostrEvent { throw Exception('Encrypted payload exceeds the accepted size'); } + // 6-11. Heavy part (two Schnorr verifications + NIP-44 decrypt, ~5 EC + // multiplications) runs off the main isolate. The cached conversation + // key is resolved here so the worker skips ECDH + HKDF; the outer event, + // key material and thrown errors transfer across the boundary. + final conversationKey = NostrUtils.conversationKeyFor( + chatKeys.conv.private, + chatKeys.conv.public, + ); + final outer = this; + final convPriv = chatKeys.conv.private; + final convPub = chatKeys.conv.public; + return Isolate.run( + () => _chatUnwrapHeavy( + outer, + convPriv, + convPub, + conversationKey, + allowedSigners, + ), + ); + } + + /// Steps 6-11 of [chatUnwrap], executed inside Isolate.run. + static Future _chatUnwrapHeavy( + NostrEvent outer, + String convPriv, + String convPub, + Uint8List conversationKey, + List allowedSigners, + ) async { // 6. Outer id and signature - _verifyEventIntegrity(this, 'outer'); + _verifyEventIntegrity(outer, 'outer'); // 7. Decrypt (NIP-44 self-decryption under K_conv) - final decrypted = await NostrUtils.decryptNIP44( - content!, - chatKeys.conv.private, - chatKeys.conv.public, + final decrypted = await Nip44.decryptMessage( + outer.content!, + convPriv, + convPub, + customConversationKey: conversationKey, ); final dynamic decoded; @@ -528,7 +562,7 @@ extension NostrEventExtensions on NostrEvent { // 11. Relative timestamp bound (stale re-wrap defense) final skew = (inner.createdAt!.millisecondsSinceEpoch ~/ 1000 - - createdAt!.millisecondsSinceEpoch ~/ 1000) + outer.createdAt!.millisecondsSinceEpoch ~/ 1000) .abs(); if (skew > chatMaxClockSkewSecs) { throw Exception('Inner and outer timestamps disagree'); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 47601fd8..baa11ed7 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -1,3 +1,4 @@ +import 'dart:isolate'; import 'dart:async'; import 'package:flutter/foundation.dart'; import 'dart:convert'; @@ -560,19 +561,26 @@ class NostrUtils { 'Unexpected author: expected $expectedAuthor, got ${event.pubkey}', ); } - if (!_isValidEventSignature(event)) { - throw ArgumentError('Invalid kind-14 event signature'); - } - - try { - return await decryptNIP44( - event.content!, - privateKey, - event.pubkey, - ); - } catch (e) { - throw Exception('Failed to decrypt NIP-44 direct event: $e'); - } + // Resolve the cached conversation key on the caller isolate, then run + // the heavy part (Schnorr verify + ChaCha20 decrypt, ~15-90 ms of pure + // Dart) off the main isolate. Strings/bytes transfer cheaply and thrown + // errors propagate. + final conversationKey = conversationKeyFor(privateKey, event.pubkey); + return Isolate.run(() async { + if (!_isValidEventSignature(event)) { + throw ArgumentError('Invalid kind-14 event signature'); + } + try { + return await Nip44.decryptMessage( + event.content!, + privateKey, + event.pubkey, + customConversationKey: conversationKey, + ); + } catch (e) { + throw Exception('Failed to decrypt NIP-44 direct event: $e'); + } + }); } /// Verifies a Nostr event's id and Schnorr signature (NIP-01): recomputes the diff --git a/test/features/disputes/dispute_chat_reload_test.dart b/test/features/disputes/dispute_chat_reload_test.dart index e252c77c..a73637e4 100644 --- a/test/features/disputes/dispute_chat_reload_test.dart +++ b/test/features/disputes/dispute_chat_reload_test.dart @@ -179,7 +179,13 @@ void main() { // Act: what LifecycleManager now does on foreground resume container.invalidate(disputeChatNotifierProvider); container.read(disputeChatNotifierProvider(disputeId).notifier); - await pumpEventQueue(times: 100); + // History unwrapping now runs through Isolate.run: wait on the observable + // condition (bounded) instead of a microtask-queue pump count. + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (container.read(disputeChatNotifierProvider(disputeId)).messages.length < 3 && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 20)); + } // Assert: the messages persisted by the background service are visible final messages = diff --git a/test/shared/utils/crypto_isolate_characterization_test.dart b/test/shared/utils/crypto_isolate_characterization_test.dart new file mode 100644 index 00000000..239cf68e --- /dev/null +++ b/test/shared/utils/crypto_isolate_characterization_test.dart @@ -0,0 +1,109 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +/// Characterization guard for moving the per-message crypto (Schnorr verify + +/// NIP-44 decrypt, ~15-90 ms of pure-Dart BigInt) off the main isolate. +/// The change is an execution-venue change only: these pins must hold +/// identically before and after, including error propagation across the +/// isolate boundary. +void main() { + const nodePriv = + '0000000000000000000000000000000000000000000000000000000000000003'; + const tradePriv = + '0000000000000000000000000000000000000000000000000000000000000004'; + final nodeKeys = NostrKeyPairs(private: nodePriv); + final tradeKeys = NostrKeyPairs(private: tradePriv); + + Future nodeMessage(String payload) async { + final encrypted = await NostrUtils.encryptNIP44( + payload, + nodePriv, + tradeKeys.public, + ); + return NostrEvent.fromPartialData( + kind: 14, + content: encrypted, + keyPairs: nodeKeys, + tags: [ + ['p', tradeKeys.public], + ], + ); + } + + test('a signed node message decrypts to its payload', () async { + final event = await nodeMessage('{"order":{"action":"ping"}}'); + + final content = await NostrUtils.decryptNIP44DirectEvent( + event, + tradePriv, + expectedAuthor: nodeKeys.public, + ); + + expect(content, '{"order":{"action":"ping"}}'); + }); + + test('an unexpected author is rejected', () async { + final event = await nodeMessage('x'); + + await expectLater( + NostrUtils.decryptNIP44DirectEvent( + event, + tradePriv, + expectedAuthor: tradeKeys.public, + ), + throwsArgumentError, + ); + }); + + test('a corrupted signature is rejected', () async { + final event = await nodeMessage('x'); + final forged = NostrEvent( + id: event.id, + kind: event.kind, + content: event.content, + sig: tradeKeys.sign(event.id!), + pubkey: event.pubkey, + createdAt: event.createdAt, + tags: event.tags, + ); + + await expectLater( + NostrUtils.decryptNIP44DirectEvent( + forged, + tradePriv, + expectedAuthor: nodeKeys.public, + ), + throwsArgumentError, + ); + }); + + test('a malformed private key is rejected before any crypto', () async { + final event = await nodeMessage('x'); + + await expectLater( + NostrUtils.decryptNIP44DirectEvent( + event, + 'nsec-not-hex', + expectedAuthor: nodeKeys.public, + ), + throwsArgumentError, + ); + }); + + test('sequential messages on one conversation decrypt correctly', () async { + final one = await nodeMessage('uno'); + final two = await nodeMessage('dos'); + + expect( + await NostrUtils.decryptNIP44DirectEvent(one, tradePriv, + expectedAuthor: nodeKeys.public), + 'uno', + ); + expect( + await NostrUtils.decryptNIP44DirectEvent(two, tradePriv, + expectedAuthor: nodeKeys.public), + 'dos', + ); + }); +}