Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
68 changes: 59 additions & 9 deletions lib/features/chat/notifiers/chat_room_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ class ChatRoomNotifier extends StateNotifier<ChatRoom> with MediaCacheMixin {
ChatKeys? _chatKeys;
String? _chatKeysSource;

/// Unwrapped inner events by outer envelope id. An envelope is verified and
/// decrypted (~5 EC multiplications) exactly once per notifier; relay
/// re-deliveries and history reloads reuse the result.
final Map<String, NostrEvent> _unwrapCache = {};
static const int _unwrapCacheLimit = 2000;

/// Test hook: number of chatUnwrap executions performed by this notifier.
@visibleForTesting
int debugUnwrapCount = 0;

NostrEvent _cacheUnwrap(String outerId, NostrEvent inner) {
if (_unwrapCache.length >= _unwrapCacheLimit) {
_unwrapCache.clear();
}
_unwrapCache[outerId] = inner;
return inner;
}

ChatRoomNotifier(
super.state,
this.orderId,
Expand Down Expand Up @@ -162,17 +180,39 @@ class ChatRoomNotifier extends StateNotifier<ChatRoom> with MediaCacheMixin {
}

// Already on disk means a relay re-delivery, an own echo, or an event
// the background service stored while the app slept. Keep processing:
// state is keyed by inner id, so only the write is redundant.
// the background service stored while the app slept. The stored copy
// was verified before being persisted, so re-verifying and
// re-decrypting it (~5 EC multiplications) is pure waste: advance the
// cursor and reuse the cached unwrap if the state lacks it (history
// load covers the cold-start case, since initialize() runs before
// subscribe()).
final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(event.id!);
final cachedUnwrap = _unwrapCache[event.id!];
if (alreadyStored && cachedUnwrap != null) {
Comment thread
grunch marked this conversation as resolved.
Outdated
// This notifier already verified and decrypted this envelope: a
// relay re-delivery only needs the cursor advanced and the cached
// inner event surfaced if state lost it. An envelope stored by the
// background isolate (not in this cache) still gets unwrapped below.
unawaited(
ref.read(chatCursorStoreProvider).advance(orderId, event.createdAt!),
Comment thread
grunch marked this conversation as resolved.
Outdated
);
if (!state.messages.any((m) => m.id == cachedUnwrap.id)) {
state = state.copy(messages: [...state.messages, cachedUnwrap]);
}
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// Unwrap and authenticate BEFORE persisting: the signature is not part
// of the event id, so storing an unverified copy would let a corrupted
// duplicate occupy the id and dedup away the valid one for good
final chat = await event.chatUnwrap(
chatKeys,
session.peerChatAllowedSigners,
debugUnwrapCount++;
final chat = _cacheUnwrap(
event.id!,
await event.chatUnwrap(
chatKeys,
session.peerChatAllowedSigners,
),
);

if (!alreadyStored) {
Expand Down Expand Up @@ -384,12 +424,22 @@ class ChatRoomNotifier extends StateNotifier<ChatRoom> with MediaCacheMixin {
});

// Decrypt and unwrap: kind 14 envelope, or legacy gift wrap
// stored before the kind-14 migration
// stored before the kind-14 migration. Envelopes already unwrapped
// by this notifier are reused instead of re-verified.
final cachedUnwrap = _unwrapCache[storedEvent.id];
if (cachedUnwrap != null) {
historicalMessages.add(cachedUnwrap);
continue;
}
final NostrEvent unwrappedMessage;
if (storedEvent.kind == 14) {
unwrappedMessage = await storedEvent.chatUnwrap(
_getChatKeys(session),
session.peerChatAllowedSigners,
debugUnwrapCount++;
unwrappedMessage = _cacheUnwrap(
storedEvent.id!,
await storedEvent.chatUnwrap(
_getChatKeys(session),
session.peerChatAllowedSigners,
),
);
} else {
if (session.sharedKey?.public != storedEvent.recipient) {
Expand Down
27 changes: 23 additions & 4 deletions lib/features/disputes/notifiers/dispute_chat_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
final Ref ref;

StreamSubscription<NostrEvent>? _subscription;

/// Outer envelope ids this notifier already verified and decrypted.

final Set<String> _unwrappedOuterIds = {};
ProviderSubscription<dynamic>? _sessionListener;
bool _isInitialized = false;

Expand Down Expand Up @@ -214,13 +218,24 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
if (event.pubkey != chatKeys.sign.public) return;

// Check for duplicate outer events (relay re-deliveries)
final wrapperEventId = event.id;
if (wrapperEventId == null) return;
final wrapperEventId = event.id!;
// Already on disk means a relay re-delivery, an own echo, or an event
// the background service stored while the app slept. Keep processing:
// state is keyed by inner id, so only the write is redundant.
// the background service stored while the app slept. The stored copy
// was verified before being persisted; skip the redundant re-verify +
// re-decrypt (history load covers cold-start state).
final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(wrapperEventId);
if (alreadyStored && _unwrappedOuterIds.contains(wrapperEventId)) {
// Verified and decrypted by this notifier before: a relay
// re-delivery only needs the cursor advanced (state dedups by inner
// id). Envelopes stored by the background isolate still unwrap below.
unawaited(
ref
.read(disputeChatCursorStoreProvider)
.advance(disputeId, event.createdAt!),
Comment thread
grunch marked this conversation as resolved.
Outdated
);
return;
}

// Unwrap and authenticate BEFORE persisting: the signature is not part
// of the event id, so storing an unverified copy would let a corrupted
Expand All @@ -229,6 +244,10 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
chatKeys,
session.disputeChatAllowedSigners,
);
if (_unwrappedOuterIds.length >= 2000) {
_unwrappedOuterIds.clear();
}
_unwrappedOuterIds.add(wrapperEventId);

// Store the outer event (encrypted) to disk — same pattern as P2P chat
if (!alreadyStored) {
Expand Down
11 changes: 11 additions & 0 deletions lib/services/mostro_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,18 @@ class MostroService {
return false;
}

/// Ids seen this run, checked synchronously: two relay copies arriving
/// within the hasItem round-trip both passed the async check and were
/// decrypted twice.
final Set<String> _seenEventIds = {};
static const int _seenEventIdsLimit = 4096;

Future<void> _onData(NostrEvent event) async {
if (_seenEventIds.length >= _seenEventIdsLimit) {
_seenEventIds.clear();
}
if (!_seenEventIds.add(event.id!)) return;
Comment thread
grunch marked this conversation as resolved.
Outdated

final eventStore = ref.read(eventStorageProvider);

if (await eventStore.hasItem(event.id!)) return;
Expand Down
143 changes: 143 additions & 0 deletions test/features/chat/chat_room_notifier_redundant_decrypt_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import 'package:dart_nostr/dart_nostr.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mostro_mobile/data/models/chat_room.dart';
import 'package:mostro_mobile/data/models/nostr_event.dart';
import 'package:mostro_mobile/data/models/peer.dart';
import 'package:mostro_mobile/data/models/session.dart';
import 'package:mostro_mobile/data/repositories/event_storage.dart';
import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart';
import 'package:mostro_mobile/features/chat/notifiers/chat_rooms_notifier.dart';
import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart';
import 'package:mostro_mobile/services/chat_cursor_store.dart';
import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart';
import 'package:mostro_mobile/shared/providers/session_notifier_provider.dart';
import 'package:mostro_mobile/shared/utils/chat_keys.dart';
import 'package:sembast/sembast_memory.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// A chat envelope costs ~5 EC multiplications to verify + decrypt. With R
/// relays each message used to be unwrapped R times (the handler continued
/// past `alreadyStored`), and every history load re-unwrapped every stored
/// envelope. Already-verified envelopes are now skipped and unwraps are
/// cached per outer id.
class _StubChatRoomsNotifier extends ChatRoomsNotifier {
_StubChatRoomsNotifier(super.ref);

@override
Future<void> loadChats() async {}

@override
Future<void> refreshChatList() async {}
}

class _FakeSharedPreferencesAsync implements SharedPreferencesAsync {
final Map<String, int> ints = {};

@override
Future<int?> getInt(String key) async => ints[key];

@override
Future<void> setInt(String key, int value) async {
ints[key] = value;
}

@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError('${invocation.memberName}');
}

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

const orderId = 'a4b7c9e1-0000-4000-8000-redundant-dec';
final ownKey = NostrKeyPairs(
private:
'0000000000000000000000000000000000000000000000000000000000000001',
);
final peerKey = NostrKeyPairs(
private:
'0000000000000000000000000000000000000000000000000000000000000002',
);

late Session session;
late EventStorage eventStorage;
late ProviderContainer container;
late ChatRoomNotifier notifier;
late ChatKeys chatKeys;

final chatRoomProvider = StateNotifierProvider<ChatRoomNotifier, ChatRoom>(
(ref) => ChatRoomNotifier(
ChatRoom(orderId: orderId, messages: []),
orderId,
ref,
),
);

setUp(() async {
session = Session(
masterKey: ownKey,
tradeKey: ownKey,
keyIndex: 1,
fullPrivacy: false,
startTime: DateTime.now(),
orderId: orderId,
)..peer = Peer(publicKey: peerKey.public);
chatKeys = ChatKeys.fromSharedKey(session.sharedKey!);

final db =
await newDatabaseFactoryMemory().openDatabase('redundant_decrypt.db');
eventStorage = EventStorage(db: db);

container = ProviderContainer(overrides: [
sessionProvider(orderId).overrideWith((ref) => session),
eventStorageProvider.overrideWithValue(eventStorage),
chatCursorStoreProvider.overrideWithValue(
ChatCursorStore(_FakeSharedPreferencesAsync(),
keyPrefix: 'chat_since_'),
),
chatRoomsNotifierProvider
.overrideWith((ref) => _StubChatRoomsNotifier(ref)),
]);
notifier = container.read(chatRoomProvider.notifier);
});

tearDown(() => container.dispose());

Future<NostrEvent> envelope(String text) {
final rumor = NostrEventExtensions.createChatRumor(
senderKeys: peerKey,
content: text,
);
return rumor.chatWrap(chatKeys);
}

test('a relay re-delivery of a stored envelope is not unwrapped again',
() async {
final event = await envelope('hola');

await notifier.handleChatEvent(event);
expect(notifier.debugUnwrapCount, 1);
expect(container.read(chatRoomProvider).messages, hasLength(1));

// Same envelope from a second relay.
await notifier.handleChatEvent(event);

expect(notifier.debugUnwrapCount, 1,
reason: 'the stored copy was already verified when first accepted');
expect(container.read(chatRoomProvider).messages, hasLength(1));
});

test('history load reuses the unwrap of a live-handled envelope', () async {
final event = await envelope('hola');
await notifier.handleChatEvent(event);
expect(notifier.debugUnwrapCount, 1);

await notifier.initialize();

expect(notifier.debugUnwrapCount, 1,
reason: 'reloading history must not re-verify and re-decrypt '
'envelopes already unwrapped in this notifier');
expect(container.read(chatRoomProvider).messages, hasLength(1));
});
}
Loading