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

/// Unwrapped inner events by outer envelope id, keyed on the *in-flight*
/// future so the reservation is made synchronously. An envelope is verified
/// and decrypted (~5 EC multiplications) exactly once per notifier: relay
/// re-deliveries, concurrent deliveries and history reloads reuse the
/// result.
final Map<String, Future<NostrEvent>> _unwrapCache = {};
static const int _unwrapCacheLimit = 2000;

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

/// Start the single unwrap of [event], or join the one already running.
/// The reservation happens synchronously — before any await — so a second
/// relay copy arriving while the first is still being verified shares its
/// result instead of paying for another verification. A failed unwrap is
/// never cached: the reservation is dropped so a later valid copy of the
/// envelope is verified normally.
Future<NostrEvent> _unwrapOnce(
NostrEvent event,
ChatKeys chatKeys,
Session session,
) {
final outerId = event.id!;
final pending = _unwrapCache[outerId];
if (pending != null) return pending;
if (_unwrapCache.length >= _unwrapCacheLimit) {
_unwrapCache.clear();
}
debugUnwrapCount++;
// The map entry is written synchronously, before the first await inside
// chatUnwrap, so a second delivery arriving meanwhile joins this future.
final unwrap = _unwrapAndForgetOnFailure(event, chatKeys, session);
_unwrapCache[outerId] = unwrap;
return unwrap;
}

Future<NostrEvent> _unwrapAndForgetOnFailure(
NostrEvent event,
ChatKeys chatKeys,
Session session,
) async {
try {
return await event.chatUnwrap(chatKeys, session.peerChatAllowedSigners);
} catch (_) {
// Never cache a rejection: a corrupted copy delivered first must not
// lock the envelope id out for the valid event that shares it.
_unwrapCache.remove(event.id!);
rethrow;
}
}

ChatRoomNotifier(
super.state,
this.orderId,
Expand Down Expand Up @@ -161,22 +213,38 @@ class ChatRoomNotifier extends StateNotifier<ChatRoom> with MediaCacheMixin {
return;
}

// 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.
final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(event.id!);
final outerId = event.id!;

// This notifier already verified and decrypted this envelope (a relay
// re-delivery, an own echo, or a copy racing an unwrap still in
// flight): reuse the verified inner event and surface it if state lost
// it. Nothing is written and the cursor is NOT advanced from here —
// this copy's signature has not been checked, and both the envelope id
// and the author key are public, so its created_at is attacker
// controlled. For legitimate traffic the advance would be a no-op
// anyway: a re-delivery carries the same id and therefore the same
// created_at, which the store already rejects as not newer.
final pending = _unwrapCache[outerId];
if (pending != null) {
final cached = await pending;
if (!state.messages.any((m) => m.id == cached.id)) {
state = state.copy(messages: [...state.messages, cached]);
}
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,
);
// duplicate occupy the id and dedup away the valid one for good. An
// envelope the background service stored while the app slept is on disk
// but not in this cache, so it is verified here like any other.
final chat = await _unwrapOnce(event, chatKeys, session);

final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(outerId);

if (!alreadyStored) {
await eventStore.putItem(event.id!, event.peerChatRecord(orderId));
await eventStore.putItem(outerId, event.peerChatRecord(orderId));
}

// Advance the persisted since cursor only after the event is accepted
Expand Down Expand Up @@ -382,12 +450,19 @@ 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(await cachedUnwrap);
continue;
}
final NostrEvent unwrappedMessage;
if (storedEvent.kind == 14) {
unwrappedMessage = await storedEvent.chatUnwrap(
unwrappedMessage = await _unwrapOnce(
storedEvent,
_getChatKeys(session),
session.peerChatAllowedSigners,
session,
);
} else {
if (session.sharedKey?.public != storedEvent.recipient) {
Expand Down
36 changes: 30 additions & 6 deletions lib/features/disputes/notifiers/dispute_chat_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
ProviderSubscription<dynamic>? _sessionListener;
bool _isInitialized = false;

/// Outer envelope ids this notifier has verified and decrypted, or is
/// currently verifying. Reserved synchronously — before any await — so two
/// relays delivering the same envelope at once share one unwrap instead of
/// each paying for the ~5 EC multiplications.
final Set<String> _unwrappedOuterIds = {};
static const int _unwrappedOuterIdsLimit = 2000;

ChatKeys? _chatKeys;
String? _chatKeysSource;

Expand Down Expand Up @@ -213,12 +220,26 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
final chatKeys = _getChatKeys(session);
if (event.pubkey != chatKeys.sign.public) return;

// Check for duplicate outer events (relay re-deliveries)
final wrapperEventId = event.id;
if (wrapperEventId == null) return;
// 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.
// Duplicate outer events (relay re-deliveries, own echoes, copies
// racing an unwrap still in flight) are dropped here: the first
// delivery verified the envelope and put its inner event in state,
// which dedups by inner id. Nothing is written and the cursor is NOT
// advanced from here — this copy's signature has not been checked, and
// both the envelope id and the author key are public, so its created_at
// is attacker controlled. For legitimate traffic the advance would be a
// no-op anyway: a re-delivery carries the same id and therefore the
// same created_at, which the store already rejects as not newer.
// The reservation is taken synchronously, before the first await.
final wrapperEventId = event.id!;
if (_unwrappedOuterIds.contains(wrapperEventId)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (_unwrappedOuterIds.length >= _unwrappedOuterIdsLimit) {
_unwrappedOuterIds.clear();
}
_unwrappedOuterIds.add(wrapperEventId);

// Already on disk means an event the background service stored while
// the app slept, or an own echo. It is not in _unwrappedOuterIds, so it
// is still verified below; only the redundant write is skipped.
final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(wrapperEventId);

Expand Down Expand Up @@ -272,6 +293,9 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
logger.i('Added dispute chat message for dispute: $disputeId '
'(from ${isFromAdmin ? "admin" : "user"})');
} catch (e, stackTrace) {
// Drop the reservation: a failed unwrap must never lock the envelope id
// out, or a later valid copy of it would be silently discarded.
if (event.id != null) _unwrappedOuterIds.remove(event.id);
logger.e('Error processing dispute chat event: $e', stackTrace: stackTrace);
}
}
Expand Down
209 changes: 209 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,209 @@
import 'dart:convert';

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;
late ChatCursorStore cursorStore;

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);

cursorStore = ChatCursorStore(
_FakeSharedPreferencesAsync(),
keyPrefix: 'chat_since_',
);

container = ProviderContainer(overrides: [
sessionProvider(orderId).overrideWith((ref) => session),
eventStorageProvider.overrideWithValue(eventStorage),
chatCursorStoreProvider.overrideWithValue(cursorStore),
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));
});

test('an envelope stored by the background isolate is still verified',
() async {
final event = await envelope('from background');
// On disk, but this notifier never verified it.
await eventStorage.putItem(event.id!, event.peerChatRecord(orderId));

await notifier.handleChatEvent(event);

expect(notifier.debugUnwrapCount, 1,
reason: 'an envelope this notifier never verified must be unwrapped, '
'even though it is already on disk');
expect(container.read(chatRoomProvider).messages, hasLength(1));
});

test('the cursor does not advance from an unverified envelope', () async {
final real = await envelope('legit');
await notifier.handleChatEvent(real);
// The accepted event advances the cursor fire-and-forget; let it land.
await Future<void>.delayed(Duration.zero);
final before = await cursorStore.cursorFor(orderId);
expect(before, isNotNull, reason: 'the verified event advances the cursor');

// Hostile relay: real envelope id, correct claimed author (both are
// public), bogus signature and a created_at far in the future.
final forged = NostrEvent.deserialized('["EVENT","",${jsonEncode({
'id': real.id,
'pubkey': chatKeys.sign.public,
'created_at': DateTime.now()
.add(const Duration(days: 3650))
.millisecondsSinceEpoch ~/
1000,
'kind': 14,
'tags': <List<String>>[],
'content': 'undecryptable garbage',
'sig': '0' * 128,
})}]');

await notifier.handleChatEvent(forged);
await Future<void>.delayed(Duration.zero);

expect(await cursorStore.cursorFor(orderId), before,
reason: 'a copy whose signature was never checked must not move the '
'since cursor: a forged created_at would push it to the local '
'clock and drop older messages after a reconnect');
});

test('concurrent deliveries of the same envelope share one unwrap',
() async {
final event = await envelope('hola');

await Future.wait([
notifier.handleChatEvent(event),
notifier.handleChatEvent(event),
]);

expect(notifier.debugUnwrapCount, 1,
reason: 'the second delivery must join the in-flight unwrap instead '
'of starting its own');
expect(container.read(chatRoomProvider).messages, hasLength(1));
});
}
Loading