From 2f39cfc8ed66e606556c85b3fe4015f517718011 Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 30 Aug 2026 21:05:17 -0300 Subject: [PATCH 1/2] perf: cache NIP-44 conversation keys per key pair Every NIP-44 encrypt/decrypt recomputed the conversation key - one EC scalar multiplication (5-30 ms of pure-Dart BigInt on a mid phone) plus HKDF - although it is constant per (our key, their key) pair: the node conversation of a session and each chat conversation reuse the same pair for every message, including the double decrypt per stored chat envelope during history loads. NostrUtils.conversationKeyFor caches the derived key in a bounded map and both encryptNIP44 and decryptNIP44 (the single choke point for all NIP-44 traffic: node kind-14 messages, chat and dispute chat) inject it through the nip44 fork's customConversationKey, which skips ECDH and HKDF. --- lib/shared/utils/nostr_utils.dart | 34 ++++++++++- .../nip44_conversation_key_cache_test.dart | 56 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 test/shared/utils/nip44_conversation_key_cache_test.dart diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 09da1dd7..84b08d12 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -342,6 +342,26 @@ class NostrUtils { return wrapEvent; } + /// Conversation keys are constant per (our key, their key) pair, but every + /// encrypt/decrypt recomputed them: one EC scalar multiplication plus HKDF + /// per message. Bounded cache; key material lives as long as the session + /// keys it derives from already do. + static const int _conversationKeyCacheLimit = 512; + static final Map _conversationKeys = {}; + + static Uint8List conversationKeyFor(String privateKey, String publicKey) { + final cacheKey = '$privateKey|$publicKey'; + final cached = _conversationKeys[cacheKey]; + if (cached != null) return cached; + final sharedSecret = Nip44.computeSharedSecret(privateKey, publicKey); + final conversationKey = Nip44.deriveConversationKey(sharedSecret); + if (_conversationKeys.length >= _conversationKeyCacheLimit) { + _conversationKeys.clear(); + } + _conversationKeys[cacheKey] = conversationKey; + return conversationKey; + } + static NostrKeyPairs computeSharedKey(String privateKey, String publicKey) { final sharedKey = Nip44.computeSharedSecret(privateKey, publicKey); final nkey = hex.encode(sharedKey); @@ -585,7 +605,12 @@ class NostrUtils { String pubkey, ) async { try { - return await Nip44.encryptMessage(content, privkey, pubkey); + return await Nip44.encryptMessage( + content, + privkey, + pubkey, + customConversationKey: conversationKeyFor(privkey, pubkey), + ); } catch (e) { // Handle encryption error appropriately throw Exception('Encryption failed: $e'); @@ -598,7 +623,12 @@ class NostrUtils { String pubkey, ) async { try { - return await Nip44.decryptMessage(encryptedContent, privkey, pubkey); + return await Nip44.decryptMessage( + encryptedContent, + privkey, + pubkey, + customConversationKey: conversationKeyFor(privkey, pubkey), + ); } catch (e) { // Handle encryption error appropriately throw Exception('Decryption failed: $e'); diff --git a/test/shared/utils/nip44_conversation_key_cache_test.dart b/test/shared/utils/nip44_conversation_key_cache_test.dart new file mode 100644 index 00000000..7392bbe9 --- /dev/null +++ b/test/shared/utils/nip44_conversation_key_cache_test.dart @@ -0,0 +1,56 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +/// Every NIP-44 encrypt/decrypt recomputed the conversation key — one EC +/// scalar multiplication (5-30 ms of BigInt math on a mid phone) plus HKDF — +/// even though it is constant per (our key, their key) pair: the node +/// conversation for a session and each chat conversation reuse the same pair +/// for every message. The key is now cached and injected through the nip44 +/// fork's `customConversationKey`. +void main() { + const alicePriv = + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const bobPriv = + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + final alicePub = NostrKeyPairs(private: alicePriv).public; + final bobPub = NostrKeyPairs(private: bobPriv).public; + + test('the conversation key is computed once per key pair', () { + final first = NostrUtils.conversationKeyFor(alicePriv, bobPub); + final second = NostrUtils.conversationKeyFor(alicePriv, bobPub); + + expect(identical(first, second), isTrue, + reason: 'repeat messages on the same conversation must not repeat ' + 'ECDH + HKDF'); + }); + + test('different pairs derive different keys', () { + final ab = NostrUtils.conversationKeyFor(alicePriv, bobPub); + final ba = NostrUtils.conversationKeyFor(bobPriv, alicePub); + final aa = NostrUtils.conversationKeyFor(alicePriv, alicePub); + + // ECDH is symmetric: both directions of one conversation agree... + expect(ab, equals(ba)); + // ...and a different pair does not. + expect(aa, isNot(equals(ab))); + }); + + test('encrypt/decrypt roundtrip works through the cached key', () async { + const content = 'mensaje de prueba nip44'; + + final cipher = await NostrUtils.encryptNIP44(content, alicePriv, bobPub); + // Prime + reuse: decrypt goes through the cache on the other side. + final plain = await NostrUtils.decryptNIP44(cipher, bobPriv, alicePub); + + expect(plain, content); + }); + + test('two messages on the same conversation decrypt correctly', () async { + final c1 = await NostrUtils.encryptNIP44('uno', alicePriv, bobPub); + final c2 = await NostrUtils.encryptNIP44('dos', alicePriv, bobPub); + + expect(await NostrUtils.decryptNIP44(c1, bobPriv, alicePub), 'uno'); + expect(await NostrUtils.decryptNIP44(c2, bobPriv, alicePub), 'dos'); + }); +} From acf658fff448644a788f1ff0645c02704a624ce5 Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 30 Aug 2026 22:11:38 -0300 Subject: [PATCH 2/2] perf: run per-message verify and decrypt off the main isolate Every incoming node message (kind 14) paid a Schnorr verification plus a NIP-44 decrypt on the UI isolate, and every chat envelope paid two verifications plus a decrypt - 15-90 ms of pure-Dart BigInt math per message, multiplied by history loads. - decryptNIP44DirectEvent keeps its cheap syntactic checks on the caller, resolves the cached conversation key there (so the worker skips ECDH + HKDF), and runs signature verification + decryption through Isolate.run. - chatUnwrap keeps its cheapest-first spec checks (author, kind, p tag, timestamp, size) on the caller and moves steps 6-11 (outer verify, decrypt, inner parse/verify/allowlist/kind/skew) into an isolate via the same pattern. The check order and every error message are unchanged. - The dispute reload test waits on the observable condition instead of a microtask pump count, since unwrapping is now cross-isolate async. The send path (wrapNip44) stays on the caller for now - one tap-driven sign per user action - noted as a follow-up. Depends on the conversation-key cache PR; this branch is stacked on it. --- lib/data/models/nostr_event.dart | 46 +++++++- lib/shared/utils/nostr_utils.dart | 34 +++--- .../disputes/dispute_chat_reload_test.dart | 8 +- .../crypto_isolate_characterization_test.dart | 109 ++++++++++++++++++ 4 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 test/shared/utils/crypto_isolate_characterization_test.dart diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 743fdecb..9ee1fa17 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'; @@ -477,14 +480,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; @@ -522,7 +556,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 84b08d12..207b3b7a 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'; @@ -526,19 +527,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', + ); + }); +}