Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions lib/data/models/nostr_event.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<NostrEvent> _chatUnwrapHeavy(
NostrEvent outer,
String convPriv,
String convPub,
Uint8List conversationKey,
List<String> 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;
Expand Down Expand Up @@ -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');
Expand Down
34 changes: 21 additions & 13 deletions lib/shared/utils/nostr_utils.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:isolate';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'dart:convert';
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion test/features/disputes/dispute_chat_reload_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>.delayed(const Duration(milliseconds: 20));
}

// Assert: the messages persisted by the background service are visible
final messages =
Expand Down
109 changes: 109 additions & 0 deletions test/shared/utils/crypto_isolate_characterization_test.dart
Original file line number Diff line number Diff line change
@@ -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<NostrEvent> 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',
);
});
}
Loading