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
126 changes: 111 additions & 15 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,59 @@ 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) {
NostrEvent? cached;
try {
cached = await pending;
} catch (_) {
// The copy holding this id failed verification. Since the signature
// is not part of the id, that copy may be a forgery reusing a valid
// envelope's id, so this one falls through and is verified on its
// own instead of being suppressed by the forgery.
cached = null;
}
if (cached != null) {
final verified = cached;
if (!state.messages.any((m) => m.id == verified.id)) {
state = state.copy(messages: [...state.messages, verified]);
}
return;
}
}

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

if (!alreadyStored) {
await eventStore.putItem(event.id!, event.peerChatRecord(orderId));
try {
final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(outerId);

if (!alreadyStored) {
await eventStore.putItem(outerId, event.peerChatRecord(orderId));
}
} catch (_) {
// Persisting failed, so this envelope is not durably handled: drop
// the cached unwrap, or a later relay delivery would take the
// already-unwrapped shortcut above and never retry the write or the
// cursor advance, and the message would be gone after a restart.
_unwrapCache.remove(outerId);
rethrow;
}

// Advance the persisted since cursor only after the event is accepted
Expand Down Expand Up @@ -382,12 +471,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
174 changes: 124 additions & 50 deletions lib/features/disputes/notifiers/dispute_chat_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
ProviderSubscription<dynamic>? _sessionListener;
bool _isInitialized = false;

/// Outer envelope ids this notifier has fully processed: verified,
/// decrypted, persisted and put in state. A later copy of one of these is a
/// pure duplicate and is dropped.
final Set<String> _unwrappedOuterIds = {};
static const int _unwrappedOuterIdsLimit = 2000;

/// Envelopes currently being processed, keyed by outer id and 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. A copy arriving mid-flight awaits this future instead
/// of being dropped: the id is only public data, so the in-flight copy may
/// be a forgery that fails verification, and the waiter must then get its
/// own chance to be verified.
final Map<String, Future<void>> _inFlightOuterIds = {};

ChatKeys? _chatKeys;
String? _chatKeysSource;

Expand Down Expand Up @@ -213,67 +228,126 @@ 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.
final eventStore = ref.read(eventStorageProvider);
final alreadyStored = await eventStore.hasItem(wrapperEventId);

// 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 unwrappedEvent = await event.chatUnwrap(
chatKeys,
session.disputeChatAllowedSigners,
);
final wrapperEventId = event.id!;

// A copy of an envelope this notifier already processed end to end (a
// relay re-delivery or an own echo): dropped. 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.
if (_unwrappedOuterIds.contains(wrapperEventId)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A copy arriving while another one holding the same id is still being
// processed waits for it instead of racing a second verification. If
// that one fails — an unverifiable forgery reuses the id of a valid
// envelope, since the signature is not part of the id — this copy falls
// through and gets verified on its own, so the forgery cannot suppress
// the real message.
final inFlight = _inFlightOuterIds[wrapperEventId];
if (inFlight != null) {
try {
await inFlight;
return;
} catch (_) {
// Fall through: the in-flight copy did not make it.
}
if (!mounted) return;
}

// Store the outer event (encrypted) to disk — same pattern as P2P chat
if (!alreadyStored) {
await eventStore.putItem(
wrapperEventId,
event.disputeChatRecord(disputeId),
);
// Reserved synchronously — the map entry is written before the first
// await below — so two relays delivering the same envelope at once
// share one unwrap.
final processing = _processChatEvent(event, session, chatKeys);
_inFlightOuterIds[wrapperEventId] = processing;
try {
await processing;
} finally {
_inFlightOuterIds.remove(wrapperEventId);
}
if (!mounted) return;
} catch (e, stackTrace) {
logger.e('Error processing dispute chat event: $e', stackTrace: stackTrace);
}
}

// Advance the persisted since cursor only after the event is accepted
// (clamped to the local clock inside the store)
unawaited(
ref
.read(disputeChatCursorStoreProvider)
.advance(disputeId, event.createdAt!),
/// Verifies, persists and displays one envelope. Throws on any failure so
/// the id is never marked processed: a later copy retries the whole path.
Future<void> _processChatEvent(
NostrEvent event,
Session session,
ChatKeys chatKeys,
) async {
final wrapperEventId = event.id!;

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

// 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 unwrappedEvent = await event.chatUnwrap(
chatKeys,
session.disputeChatAllowedSigners,
);

// Store the outer event (encrypted) to disk — same pattern as P2P chat
if (!alreadyStored) {
await eventStore.putItem(
wrapperEventId,
event.disputeChatRecord(disputeId),
);
}
if (!mounted) return;

final messageText = unwrappedEvent.content ?? '';
if (messageText.isEmpty) {
logger.w('Received empty message, skipping');
return;
}
// Advance the persisted since cursor only after the event is accepted
// (clamped to the local clock inside the store)
unawaited(
ref
.read(disputeChatCursorStoreProvider)
.advance(disputeId, event.createdAt!),
);

final isFromAdmin = unwrappedEvent.pubkey != session.tradeKey.public;
final message = DisputeChatMessage(event: unwrappedEvent);
// Only now is the envelope done: everything that could throw (unwrap,
// disk) has succeeded, so a copy arriving later is a true duplicate. A
// failure above leaves the id unmarked and the next copy retries.
_markOuterProcessed(wrapperEventId);

// Dedup by inner event ID (handles relay echo of sent messages)
final allMessages = [...state.messages, message];
final deduped = {for (var m in allMessages) m.id: m}.values.toList();
deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp));
final messageText = unwrappedEvent.content ?? '';
if (messageText.isEmpty) {
logger.w('Received empty message, skipping');
return;
}

state = state.copyWith(messages: deduped);
final isFromAdmin = unwrappedEvent.pubkey != session.tradeKey.public;
final message = DisputeChatMessage(event: unwrappedEvent);

if (isFromAdmin) {
_maybeShowInAppNotification();
}
// Dedup by inner event ID (handles relay echo of sent messages)
final allMessages = [...state.messages, message];
final deduped = {for (var m in allMessages) m.id: m}.values.toList();
deduped.sort((a, b) => a.timestamp.compareTo(b.timestamp));

// Fire-and-forget: pre-download media after message is in state
unawaited(_processMessageContent(unwrappedEvent));
logger.i('Added dispute chat message for dispute: $disputeId '
'(from ${isFromAdmin ? "admin" : "user"})');
} catch (e, stackTrace) {
logger.e('Error processing dispute chat event: $e', stackTrace: stackTrace);
state = state.copyWith(messages: deduped);

if (isFromAdmin) {
_maybeShowInAppNotification();
}

// Fire-and-forget: pre-download media after message is in state
unawaited(_processMessageContent(unwrappedEvent));
logger.i('Added dispute chat message for dispute: $disputeId '
'(from ${isFromAdmin ? "admin" : "user"})');
}

void _markOuterProcessed(String wrapperEventId) {
if (_unwrappedOuterIds.length >= _unwrappedOuterIdsLimit) {
_unwrappedOuterIds.clear();
}
_unwrappedOuterIds.add(wrapperEventId);
}

/// Show an in-app snackbar for incoming admin messages when the user is not
Expand Down
Loading
Loading