Merge pull request #430 from twonlyapp/hotfix

Fix: Recover broken sessions more reliably
This commit is contained in:
Tobi 2026-09-25 01:15:27 +02:00 committed by GitHub
commit c1f4446653
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1669 additions and 133 deletions

View file

@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.5.4
- Fix: Recover broken sessions more reliably
## 0.5.3 ## 0.5.3
- Hotfix: Fixed stuck Signal sessions - Hotfix: Fixed stuck Signal sessions

View file

@ -13,9 +13,9 @@ class LoggingCallbacks {
dartLogSink.stream.listen( dartLogSink.stream.listen(
(log) { (log) {
if (log.contains('INFO ')) { if (log.contains('INFO ')) {
Log.info(log.split('INFO ')[1]); Log.rust(log.split('INFO ')[1]);
} else if (log.contains('DEBUG ')) { } else if (log.contains('DEBUG ')) {
Log.info(log.split('DEBUG ')[1]); Log.rust(log.split('DEBUG ')[1]);
} else if (kDebugMode && !Platform.environment.containsKey('FLUTTER_TEST')) { } else if (kDebugMode && !Platform.environment.containsKey('FLUTTER_TEST')) {
// ignore: avoid_print // ignore: avoid_print
print(log); print(log);

View file

@ -249,6 +249,14 @@ class ApiService {
bool get isConnected => _channel != null && _channel!.closeCode == null; bool get isConnected => _channel != null && _channel!.closeCode == null;
Future<void> _onDone() async { Future<void> _onDone() async {
final channel = _channel;
if (channel != null) {
// Not closed by us. The server's close code tells why it dropped the
// connection, e.g. 1013 (outbound queue full) or 4003 (ack timeouts).
Log.info(
'websocket closed with code ${channel.closeCode}: ${channel.closeReason}',
);
}
_reconnectionDelay = 3; _reconnectionDelay = 3;
await onClosed(); await onClosed();
} }

View file

@ -52,16 +52,14 @@ Future<void> reuploadMediaFiles() async {
final contacts = <int, Contact>{}; final contacts = <int, Contact>{};
for (final receipt in receipts) { for (final receipt in receipts) {
if (receipt.retryCount > 1 && receipt.lastRetry != null) { if (!isRetransmissionDue(
final twentyFourHoursAgo = DateTime.now().subtract( receipt,
const Duration(hours: 6), minimumPause: const Duration(hours: 6),
)) {
Log.info(
'Ignoring ${receipt.receiptId} as its next retry is not due yet',
); );
if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) { continue;
Log.info(
'Ignoring ${receipt.receiptId} as it was retried in the last 6h',
);
continue;
}
} }
var messageId = receipt.messageId; var messageId = receipt.messageId;

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
@ -36,7 +37,11 @@ Future<void> _resetStuckOutgoingSignalSessions() async {
); );
for (final contactId in contactIds) { for (final contactId in contactIds) {
if (!shouldAttemptResync(contactId)) continue; final contact = await twonlyDB.contactsDao.getContactById(contactId);
if (contact == null ||
!shouldAttemptResync(contactId, contact.signalVersion)) {
continue;
}
Log.warn( Log.warn(
'Detected an out-of-sync outgoing Signal session with $contactId: ' 'Detected an out-of-sync outgoing Signal session with $contactId: '
@ -53,7 +58,7 @@ Future<void> _resetStuckOutgoingSignalSessions() async {
} }
if (!reset) { if (!reset) {
recordResyncAttempt(contactId, success: false); recordResyncAttempt(contactId, contact.signalVersion, success: false);
continue; continue;
} }
@ -64,11 +69,35 @@ Future<void> _resetStuckOutgoingSignalSessions() async {
} }
} }
/// Whether a message that the server already accepted may be sent again.
///
/// Every valid message from a contact marks everything they have not
/// confirmed for retry. When their confirmation got lost, they stay silent
/// until their duplicate cooldown ends, ten days on 0.5.2, so sending again on
/// every message from them only floods their device. After the first retry the
/// pause doubles with every attempt, from one hour up to a day, but never
/// drops below [minimumPause].
bool isRetransmissionDue(
Receipt receipt, {
Duration minimumPause = Duration.zero,
}) {
final lastRetry = receipt.lastRetry;
if (receipt.retryCount <= 1 || lastRetry == null) return true;
var pause = Duration(hours: min(1 << min(receipt.retryCount - 2, 5), 24));
if (pause < minimumPause) pause = minimumPause;
return !clock.now().isBefore(lastRetry.add(pause));
}
Future<void> retransmitAllMessages() async { Future<void> retransmitAllMessages() async {
return lockRetransmission.protect(() async { return lockRetransmission.protect(() async {
await _resetStuckOutgoingSignalSessions(); await _resetStuckOutgoingSignalSessions();
final receipts = await twonlyDB.receiptsDao.getReceiptsForRetransmission(); final receipts = (await twonlyDB.receiptsDao.getReceiptsForRetransmission())
.where(
(receipt) =>
receipt.ackByServerAt == null || isRetransmissionDue(receipt),
)
.toList();
if (receipts.isEmpty) return; if (receipts.isEmpty) return;
@ -102,6 +131,13 @@ Future<void> retransmitAllMessages() async {
}); });
} }
Future<SignalEncryptResult?> _encryptFor(
Contact contact,
Uint8List plaintext,
) => contact.signalVersion == SignalVersion.v2
? signalEncryptMessageV2(contact.userId, plaintext)
: signalEncryptMessage(contact.userId, plaintext);
final Map<String, Mutex> _tryToSendLocks = {}; final Map<String, Mutex> _tryToSendLocks = {};
// When the ackByServerAt is set this value is written in the receipted // When the ackByServerAt is set this value is written in the receipted
@ -162,6 +198,11 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
return null; return null;
} }
if (contact.signalVersion == SignalVersion.v1) {
// Pending receipts make this an active session too, including messages
// created before active-session migration was introduced.
scheduleSignalSessionUpgrade(contact.userId);
}
if (!onlyReturnEncryptedData && if (!onlyReturnEncryptedData &&
receipt.ackByServerAt != null && receipt.ackByServerAt != null &&
@ -201,23 +242,41 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({
if (message.type == pb.Message_Type.CIPHERTEXT || if (message.type == pb.Message_Type.CIPHERTEXT ||
message.type == pb.Message_Type.CIPHERTEXT_V2) { message.type == pb.Message_Type.CIPHERTEXT_V2) {
final useV2 = contact.signalVersion == SignalVersion.v2; final plaintext = Uint8List.fromList(message.encryptedContent);
final encryptResult = useV2 var target = contact;
? await signalEncryptMessageV2( var encryptResult = await _encryptFor(target, plaintext);
receipt.contactId,
Uint8List.fromList(message.encryptedContent), if (encryptResult == null &&
) shouldAttemptResync(receipt.contactId, target.signalVersion)) {
: await signalEncryptMessage( // Usually there is no session to encrypt with, e.g. for a contact
receipt.contactId, // marked V2 that the Rust store holds no session for. Building one
Uint8List.fromList(message.encryptedContent), // from the contact's current bundle can also move them to V2.
); final resynced = await handleSessionResync(receipt.contactId);
if (resynced) {
target =
await twonlyDB.contactsDao.getContactById(receipt.contactId) ??
target;
encryptResult = await _encryptFor(target, plaintext);
}
recordResyncAttempt(
receipt.contactId,
target.signalVersion,
success: false,
);
}
if (encryptResult == null) { if (encryptResult == null) {
Log.error( Log.error(
'[${receipt.receiptId}] Could not encrypt the message ' '[${receipt.receiptId}] Could not encrypt the message '
'(${useV2 ? 'V2' : 'V1'}) for user ${receipt.contactId}. ' '(${target.signalVersion == SignalVersion.v2 ? 'V2' : 'V1'}) '
'Aborting and trying again.', 'for user ${receipt.contactId}.',
); );
if (!onlyReturnEncryptedData) {
// The receipt stays, so the next retransmission tries again.
return null;
}
// The media upload goes out without this contact. Its receipt would
// otherwise be marked as uploaded together with the others.
if (receipt.messageId != null) { if (receipt.messageId != null) {
await twonlyDB.messagesDao.handleMessageAckByServer( await twonlyDB.messagesDao.handleMessageAckByServer(
receipt.contactId, receipt.contactId,

View file

@ -9,6 +9,7 @@ import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart' hide Message; import 'package:twonly/src/database/twonly.db.dart' hide Message;
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart' import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart'
as client; as client;
@ -39,23 +40,6 @@ import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
const _signalUpgradeMailboxIdleDelay = Duration(milliseconds: 500);
Timer? _signalUpgradeMailboxIdleTimer;
void _scheduleSignalUpgradeAfterMailboxIdle() {
_signalUpgradeMailboxIdleTimer?.cancel();
_signalUpgradeMailboxIdleTimer = Timer(_signalUpgradeMailboxIdleDelay, () {
_signalUpgradeMailboxIdleTimer = null;
unawaited(upgradeSignalSessionsToV2());
});
}
void _upgradeSignalSessionsAfterMailboxDrain() {
_signalUpgradeMailboxIdleTimer?.cancel();
_signalUpgradeMailboxIdleTimer = null;
unawaited(upgradeSignalSessionsToV2());
}
Future<void> handleServerMessage(server.ServerToClient msg) async { Future<void> handleServerMessage(server.ServerToClient msg) async {
Log.info('Processing a message from the server.'); Log.info('Processing a message from the server.');
var receivedIncomingMessages = false; var receivedIncomingMessages = false;
@ -78,7 +62,7 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
Log.info( Log.info(
'Got ${msg.v0.newMessages.newMessages.length} messages from the server.', 'Got ${msg.v0.newMessages.newMessages.length} messages from the server.',
); );
final brokenSessionsInCurrentBatch = <int>{}; final brokenSessionsInCurrentBatch = <(int, SignalVersion)>{};
for (final newMessage in msg.v0.newMessages.newMessages) { for (final newMessage in msg.v0.newMessages.newMessages) {
try { try {
await handleClient2ClientMessage( await handleClient2ClientMessage(
@ -116,12 +100,12 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
Log.info('All messages from the server processed.'); Log.info('All messages from the server processed.');
if (mailboxDrained) { if (mailboxDrained) {
_upgradeSignalSessionsAfterMailboxDrain(); flushActiveSignalSessionUpgrades();
} else if (receivedIncomingMessages) { } else if (receivedIncomingMessages) {
// Released servers do not send a mailbox-drained marker. They wait for // Released servers do not send a mailbox-drained marker. They wait for
// this ACK before sending the next batch, so a short, resettable idle // this ACK before sending the next batch, so a resettable idle window
// window approximates the queue boundary without blocking delivery. // approximates the queue boundary without blocking delivery.
_scheduleSignalUpgradeAfterMailboxIdle(); scheduleActiveSignalSessionUpgrades();
} }
} }
@ -131,13 +115,17 @@ final Map<String, Mutex> _messageLocks = {};
Future<void> handleClient2ClientMessage( Future<void> handleClient2ClientMessage(
NewMessage newMessage, { NewMessage newMessage, {
Set<int>? brokenSessionsInCurrentBatch, BrokenSignalSessions? brokenSessionsInCurrentBatch,
}) async { }) async {
final body = Uint8List.fromList(newMessage.body); final body = Uint8List.fromList(newMessage.body);
final message = Message.fromBuffer(body); final message = Message.fromBuffer(body);
final receiptId = message.receiptId; final receiptId = message.receiptId;
// Replies such as delivery receipts and decryption errors reuse the
// receipt id of the message they answer, so only the sender and the id
// together identify an incoming message.
final lockKey = '${newMessage.fromUserId}:$receiptId';
final mutex = _messageLocks.putIfAbsent(receiptId, Mutex.new); final mutex = _messageLocks.putIfAbsent(lockKey, Mutex.new);
if (mutex.isLocked) { if (mutex.isLocked) {
Log.info( Log.info(
'[$receiptId] Skipping — already being processed by another handler', '[$receiptId] Skipping — already being processed by another handler',
@ -152,7 +140,7 @@ Future<void> handleClient2ClientMessage(
brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch, brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch,
); );
} finally { } finally {
_messageLocks.remove(receiptId); _messageLocks.remove(lockKey);
} }
}); });
} }
@ -160,12 +148,25 @@ Future<void> handleClient2ClientMessage(
Future<void> _handleClient2ClientMessage( Future<void> _handleClient2ClientMessage(
NewMessage newMessage, NewMessage newMessage,
Message message, { Message message, {
Set<int>? brokenSessionsInCurrentBatch, BrokenSignalSessions? brokenSessionsInCurrentBatch,
}) async { }) async {
final fromUserId = newMessage.fromUserId.toInt(); final fromUserId = newMessage.fromUserId.toInt();
final receiptId = message.receiptId; final receiptId = message.receiptId;
if (brokenSessionsInCurrentBatch?.contains(fromUserId) == true) { final signalVersion = switch (message.type) {
Message_Type.CIPHERTEXT_V2 => SignalVersion.v2,
Message_Type.CIPHERTEXT || Message_Type.PREKEY_BUNDLE => SignalVersion.v1,
// Receipts and decryption errors are not encrypted and are never skipped.
_ => null,
};
if (signalVersion == SignalVersion.v1) {
// Only sessions participating in current traffic are migration targets.
// Each message resets the idle window so a server batch finishes first.
queueSignalSessionUpgrade(fromUserId);
}
if (signalVersion != null &&
brokenSessionsInCurrentBatch?.contains((fromUserId, signalVersion)) ==
true) {
// This happens when a session goes out of sync (e.g. wrong message order). // This happens when a session goes out of sync (e.g. wrong message order).
// We skip the remaining messages in the batch because each failed decryption // We skip the remaining messages in the batch because each failed decryption
// attempt is extremely slow (~1.2s) and would otherwise freeze the app. // attempt is extremely slow (~1.2s) and would otherwise freeze the app.
@ -181,13 +182,20 @@ Future<void> _handleClient2ClientMessage(
} }
if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) { if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) {
if (message.type == Message_Type.SENDER_DELIVERY_RECEIPT) { if (signalVersion == null) {
// Only encrypted messages are confirmed. The others, delivery receipts
// and decryption errors, answer one of our messages: the first copy
// was handled, and answering a reply starts a loop.
Log.info( Log.info(
'[$receiptId] Delivery receipt is a duplicate. Skipping receipt response.', '[$receiptId] Unencrypted message is a duplicate. Skipping receipt response.',
); );
return; return;
} }
const duplicateReceiptCooldown = Duration(days: 10); // A sender only retries a message it never saw confirmed, so our
// delivery receipt got lost and has to be sent again. The cooldown only
// keeps a batch the server delivers twice from being answered twice.
// 0.5.2 waited ten days here while the sender kept retrying.
const duplicateReceiptCooldown = Duration(hours: 1);
final shouldResend = await twonlyDB.receiptsDao.claimDuplicateReceiptResend( final shouldResend = await twonlyDB.receiptsDao.claimDuplicateReceiptResend(
receiptId, receiptId,
duplicateReceiptCooldown, duplicateReceiptCooldown,
@ -223,6 +231,12 @@ Future<void> _handleClient2ClientMessage(
Log.info('[$receiptId] Started processing message'); Log.info('[$receiptId] Started processing message');
// A message we could not process is answered with an error, and the sender
// sends it again. Remembering its receipt id would turn the next copy into
// a duplicate, which is answered with a delivery receipt for content we
// never read, once the error reply got lost.
var processed = true;
switch (message.type) { switch (message.type) {
case Message_Type.SENDER_DELIVERY_RECEIPT: case Message_Type.SENDER_DELIVERY_RECEIPT:
Log.info('[$receiptId] Got delivery receipt!'); Log.info('[$receiptId] Got delivery receipt!');
@ -329,6 +343,7 @@ Future<void> _handleClient2ClientMessage(
} }
response ??= Message(type: Message_Type.SENDER_DELIVERY_RECEIPT); response ??= Message(type: Message_Type.SENDER_DELIVERY_RECEIPT);
processed = response.type != Message_Type.PLAINTEXT_CONTENT;
String? targetReceiptId; String? targetReceiptId;
try { try {
@ -359,7 +374,7 @@ Future<void> _handleClient2ClientMessage(
} }
try { try {
await twonlyDB.receiptsDao.gotReceipt(receiptId); if (processed) await twonlyDB.receiptsDao.gotReceipt(receiptId);
Log.info('[$receiptId] Finished processing'); Log.info('[$receiptId] Finished processing');
} catch (e) { } catch (e) {
Log.warn('[$receiptId] Error marking message as received: $e'); Log.warn('[$receiptId] Error marking message as received: $e');
@ -375,7 +390,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
Uint8List encryptedContentRaw, Uint8List encryptedContentRaw,
Message_Type messageType, Message_Type messageType,
String receiptId, { String receiptId, {
Set<int>? brokenSessionsInCurrentBatch, BrokenSignalSessions? brokenSessionsInCurrentBatch,
}) async { }) async {
Log.info('[$receiptId] calling signalDecryptMessage'); Log.info('[$receiptId] calling signalDecryptMessage');
EncryptedContent? encryptedContent; EncryptedContent? encryptedContent;

View file

@ -1,9 +1,13 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:drift/drift.dart' as drift;
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:libsignal_protocol_dart/src/invalid_message_exception.dart'; import 'package:libsignal_protocol_dart/src/invalid_message_exception.dart';
import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/core/bridge/wrapper/signal.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb; as pb;
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
@ -72,11 +76,15 @@ Future<SignalEncryptResult?> _signalEncryptMessageV1(
} }
} }
/// Contacts whose session failed in the current batch of server messages, per
/// Signal version: a broken V1 session says nothing about the V2 session.
typedef BrokenSignalSessions = Set<(int, SignalVersion)>;
Future<(pb.EncryptedContent?, pb.PlaintextContent_DecryptionErrorMessage_Type?)> Future<(pb.EncryptedContent?, pb.PlaintextContent_DecryptionErrorMessage_Type?)>
signalDecryptMessageV2( signalDecryptMessageV2(
int fromUserId, int fromUserId,
Uint8List encryptedContentRaw, { Uint8List encryptedContentRaw, {
Set<int>? brokenSessionsInCurrentBatch, BrokenSignalSessions? brokenSessionsInCurrentBatch,
}) async { }) async {
Log.info('Acquiring lockingSignalProtocol for $fromUserId (V2)'); Log.info('Acquiring lockingSignalProtocol for $fromUserId (V2)');
final ( final (
@ -91,9 +99,20 @@ signalDecryptMessageV2(
deviceId: 1, deviceId: 1,
ciphertext: encryptedContentRaw, ciphertext: encryptedContentRaw,
); );
recordResyncAttempt(fromUserId, success: true); recordResyncAttempt(fromUserId, SignalVersion.v2, success: true);
return (pb.EncryptedContent.fromBuffer(plaintext), null, false); return (pb.EncryptedContent.fromBuffer(plaintext), null, false);
} catch (e) { } catch (e) {
if (_isDuplicatedMessageError(e)) {
// Same as the DuplicateMessageException in V1: the message was
// decrypted before but not processed, so the session is intact and
// only the message has to be requested again.
Log.info('Message (V2) from $fromUserId was already decrypted: $e');
return (
null,
pb.PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN,
false,
);
}
Log.error('Could not decrypt message (V2) from $fromUserId: $e'); Log.error('Could not decrypt message (V2) from $fromUserId: $e');
return ( return (
null, null,
@ -105,11 +124,15 @@ signalDecryptMessageV2(
Log.info('Released lockingSignalProtocol for $fromUserId (V2)'); Log.info('Released lockingSignalProtocol for $fromUserId (V2)');
if (decryptedContent != null) {
await _useSignalV2For(fromUserId);
}
if (needsResync) { if (needsResync) {
brokenSessionsInCurrentBatch?.add(fromUserId); brokenSessionsInCurrentBatch?.add((fromUserId, SignalVersion.v2));
if (shouldAttemptResync(fromUserId)) { if (shouldAttemptResync(fromUserId, SignalVersion.v2)) {
if (await handleSessionResync(fromUserId)) { if (await handleSessionResync(fromUserId)) {
recordResyncAttempt(fromUserId, success: false); recordResyncAttempt(fromUserId, SignalVersion.v2, success: false);
await sendCipherText( await sendCipherText(
fromUserId, fromUserId,
pb.EncryptedContent( pb.EncryptedContent(
@ -130,7 +153,7 @@ signalDecryptMessageV1(
int fromUserId, int fromUserId,
Uint8List encryptedContentRaw, Uint8List encryptedContentRaw,
int type, { int type, {
Set<int>? brokenSessionsInCurrentBatch, BrokenSignalSessions? brokenSessionsInCurrentBatch,
}) async { }) async {
Log.info('Acquiring lockingSignalProtocol for $fromUserId (V1)'); Log.info('Acquiring lockingSignalProtocol for $fromUserId (V1)');
final ( final (
@ -143,10 +166,9 @@ signalDecryptMessageV1(
// Yield execution to the event loop to prevent UI freezing during bulk decryption // Yield execution to the event loop to prevent UI freezing during bulk decryption
await Future.delayed(Duration.zero); await Future.delayed(Duration.zero);
final session = SessionCipher.fromStore( final signalStore = (await getSignalStore())!;
(await getSignalStore())!, final address = getSignalAddress(fromUserId);
getSignalAddress(fromUserId), final session = SessionCipher.fromStore(signalStore, address);
);
Uint8List plaintext; Uint8List plaintext;
@ -156,9 +178,17 @@ signalDecryptMessageV1(
PreKeySignalMessage(encryptedContentRaw), PreKeySignalMessage(encryptedContentRaw),
); );
case CiphertextMessage.whisperType: case CiphertextMessage.whisperType:
plaintext = await session.decryptFromSignal( final message = SignalMessage.fromSerialized(encryptedContentRaw);
SignalMessage.fromSerialized(encryptedContentRaw), if (isBeyondReceivingWindow(
); await signalStore.loadSession(address),
message,
)) {
throw InvalidMessageException(
'No valid sessions. Counter ${message.getCounter()} is beyond '
'the receiving window of every session state.',
);
}
plaintext = await session.decryptFromSignal(message);
default: default:
Log.error('Unknown Message Decryption Type: $type'); Log.error('Unknown Message Decryption Type: $type');
return ( return (
@ -168,7 +198,7 @@ signalDecryptMessageV1(
); );
} }
recordResyncAttempt(fromUserId, success: true); recordResyncAttempt(fromUserId, SignalVersion.v1, success: true);
return (pb.EncryptedContent.fromBuffer(plaintext), null, false); return (pb.EncryptedContent.fromBuffer(plaintext), null, false);
} on InvalidKeyIdException catch (e) { } on InvalidKeyIdException catch (e) {
Log.warn(e); Log.warn(e);
@ -209,26 +239,98 @@ signalDecryptMessageV1(
// Handle session resync OUTSIDE the lock to avoid holding it during // Handle session resync OUTSIDE the lock to avoid holding it during
// network round-trips (which can block for up to 60 seconds) // network round-trips (which can block for up to 60 seconds)
if (needsResync) { if (needsResync) {
brokenSessionsInCurrentBatch?.add(fromUserId); brokenSessionsInCurrentBatch?.add((fromUserId, SignalVersion.v1));
if (shouldAttemptResync(fromUserId)) { if (shouldAttemptResync(fromUserId, SignalVersion.v1)) {
if (await handleSessionResync(fromUserId)) { return (decryptedContent, await _recoverFromBrokenV1Session(fromUserId));
// This flag prevents from resyncing the session the client received
// multiple new messages from the server he could not decrypt
recordResyncAttempt(fromUserId, success: false);
// This message contains a new PreKeyBundle establishing a new signal
// session
await sendCipherText(
fromUserId,
pb.EncryptedContent(
errorMessages: pb.EncryptedContent_ErrorMessages(
type: pb.EncryptedContent_ErrorMessages_Type.SESSION_OUT_OF_SYNC,
),
),
);
}
} }
} }
return (decryptedContent, errorType); return (decryptedContent, errorType);
} }
/// Repairs the connection after a V1 message could not be decrypted and
/// returns the decryption error to report back to the sender.
Future<pb.PlaintextContent_DecryptionErrorMessage_Type>
_recoverFromBrokenV1Session(int fromUserId) async {
var contact = await twonlyDB.contactsDao.getContactById(fromUserId);
if (contact?.signalVersion == SignalVersion.v2) {
// Our V2 session is not affected, rebuilding it would not help.
recordResyncAttempt(fromUserId, SignalVersion.v1, success: false);
} else {
if (!await handleSessionResync(fromUserId)) {
return pb.PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN;
}
// This flag prevents from resyncing the session the client received
// multiple new messages from the server he could not decrypt
recordResyncAttempt(fromUserId, SignalVersion.v1, success: false);
// This message contains a new PreKeyBundle establishing a new signal
// session
await sendCipherText(
fromUserId,
pb.EncryptedContent(
errorMessages: pb.EncryptedContent_ErrorMessages(
type: pb.EncryptedContent_ErrorMessages_Type.SESSION_OUT_OF_SYNC,
),
),
);
contact = await twonlyDB.contactsDao.getContactById(fromUserId);
if (contact?.signalVersion != SignalVersion.v2) {
return pb.PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN;
}
}
// We answer this contact over V2, so a SESSION_OUT_OF_SYNC travels over V2
// as well and leaves the sender's V1 session broken: versions before 0.5.3
// keep sending V1 until they process our bundle. PREKEY_UNKNOWN makes the
// sender (any version) rebuild its session from our current bundle, which
// is V2 once we publish one, and resend the message.
return pb.PlaintextContent_DecryptionErrorMessage_Type.PREKEY_UNKNOWN;
}
/// Receiving over V2 proves that the Rust store holds a working session with
/// the sender. A contact still marked V1 is switched, so replies no longer go
/// over a V1 session that the sender may have repaired over V2 only.
Future<void> _useSignalV2For(int contactId) async {
final contact = await twonlyDB.contactsDao.getContactById(contactId);
if (contact == null || contact.signalVersion == SignalVersion.v2) return;
Log.info('Got a V2 message from $contactId, switching the contact to V2.');
await twonlyDB.contactsDao.updateContact(
contactId,
const ContactsCompanion(signalVersion: drift.Value(SignalVersion.v2)),
);
}
/// Rust reports libsignal's DuplicatedMessage as
/// `TwonlyError::DuplicatedSignalMessage`, which reaches Dart as text only.
bool _isDuplicatedMessageError(Object error) =>
error.toString().contains('Duplicated Signal message');
/// libsignal_protocol_dart refuses to skip more message keys than this on a
/// single receiving chain (see SessionCipher._getOrCreateMessageKeys).
const _maxSkippedMessageKeys = 2000;
/// Whether libsignal_protocol_dart is certain to reject [message] because its
/// counter lies more than [_maxSkippedMessageKeys] ahead in every session state
/// that could otherwise decrypt it.
///
/// libsignal only reaches that verdict after deriving a new receiving chain,
/// two ECDH operations and a key generation, for each archived state. With a
/// few dozen states that blocks the isolate for seconds per message, while the
/// chain indices alone give the same answer.
bool isBeyondReceivingWindow(SessionRecord record, SignalMessage message) {
final ratchetKey = message.getSenderRatchetKey();
final counter = message.getCounter();
var rejectedStates = 0;
for (final state in [record.sessionState, ...record.previousSessionStates]) {
// libsignal rejects these states before it looks at the counter.
if (!state.hasSenderChain() ||
state.getSessionVersion() != message.getMessageVersion()) {
continue;
}
// Without a chain for this ratchet key libsignal starts one at index 0.
final index = state.getReceiverChainKey(ratchetKey)?.index ?? 0;
if (counter - index <= _maxSkippedMessageKeys) return false;
rejectedStates++;
}
return rejectedStates > 0;
}

View file

@ -1,29 +1,39 @@
import 'dart:math'; import 'dart:math';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
/// Unified lock for all Signal protocol operations (encryption, decryption, session management). /// Unified lock for all Signal protocol operations (encryption, decryption, session management).
final lockingSignalProtocol = Mutex(); final lockingSignalProtocol = Mutex();
/// Tracking users who have already been resynced in the current session. /// Tracks recovery attempts independently for the legacy and PQ sessions of a
final Map<int, ({int failureCount, DateTime lastAttempt})> _resyncAttempts = {}; /// contact. A stale V1 message must not prevent V2 recovery (or vice versa).
final Map<(int, SignalVersion), ({int failureCount, DateTime lastAttempt})>
_resyncAttempts = {};
const int maxResyncAttempts = 3; const int maxResyncAttempts = 3;
bool shouldAttemptResync(int userId) { bool shouldAttemptResync(int userId, SignalVersion signalVersion) {
final attempt = _resyncAttempts[userId]; final attempt = _resyncAttempts[(userId, signalVersion)];
if (attempt == null) return true; if (attempt == null) return true;
if (attempt.failureCount >= maxResyncAttempts) return false; if (attempt.failureCount >= maxResyncAttempts) return false;
final cooldown = Duration(minutes: 5 * pow(5, attempt.failureCount - 1).toInt()); final cooldown = Duration(
minutes: 5 * pow(5, attempt.failureCount - 1).toInt(),
);
return DateTime.now().difference(attempt.lastAttempt) > cooldown; return DateTime.now().difference(attempt.lastAttempt) > cooldown;
} }
void recordResyncAttempt(int userId, {required bool success}) { void recordResyncAttempt(
int userId,
SignalVersion signalVersion, {
required bool success,
}) {
final key = (userId, signalVersion);
if (success) { if (success) {
_resyncAttempts.remove(userId); _resyncAttempts.remove(key);
} else { } else {
final current = _resyncAttempts[userId]; final current = _resyncAttempts[key];
_resyncAttempts[userId] = ( _resyncAttempts[key] = (
failureCount: (current?.failureCount ?? 0) + 1, failureCount: (current?.failureCount ?? 0) + 1,
lastAttempt: DateTime.now(), lastAttempt: DateTime.now(),
); );

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
@ -14,7 +15,24 @@ import 'package:twonly/src/services/signal/protocol_state.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart'; import 'package:twonly/src/services/signal/utils.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
final Mutex _upgradeSignalSessionsLock = Mutex(); class _SignalUpgradeState {
final lock = Mutex();
final activeV1Contacts = <int>{};
Timer? idleTimer;
}
/// Production isolates have one database, while integration tests host
/// multiple clients in zones in the same isolate. Keeping the queue per
/// database prevents one client's activity from being migrated in another
/// client's Signal store.
final Map<TwonlyDB, _SignalUpgradeState> _signalUpgradeStates = {};
_SignalUpgradeState get _signalUpgradeState => _signalUpgradeStates.putIfAbsent(
twonlyDB,
_SignalUpgradeState.new,
);
const _signalUpgradeIdleDelay = Duration(milliseconds: 500);
Future<bool> processSignalUserData(Response_UserData userData) async { Future<bool> processSignalUserData(Response_UserData userData) async {
return lockingSignalProtocol.protect(() async { return lockingSignalProtocol.protect(() async {
@ -22,38 +40,78 @@ Future<bool> processSignalUserData(Response_UserData userData) async {
}); });
} }
/// Upgrades every known v1 contact that currently publishes a PQXDH bundle. /// Adds a V1 session to the active migration set without starting migration.
/// Incoming batches use this so migration cannot begin midway through a slow
/// decryption pass.
void queueSignalSessionUpgrade(int contactId) {
final state = _signalUpgradeState;
state.activeV1Contacts.add(contactId);
}
/// Starts or resets the idle window for queued active V1 sessions.
void scheduleActiveSignalSessionUpgrades() {
final state = _signalUpgradeState;
state.idleTimer?.cancel();
state.idleTimer = Timer(_signalUpgradeIdleDelay, () {
state.idleTimer = null;
unawaited(upgradeActiveSignalSessionsToV2());
});
}
/// Marks an outgoing V1 session as active and schedules its migration.
void scheduleSignalSessionUpgrade(int contactId) {
queueSignalSessionUpgrade(contactId);
scheduleActiveSignalSessionUpgrades();
}
/// Runs pending active-session upgrades immediately after the mailbox drains.
void flushActiveSignalSessionUpgrades() {
final state = _signalUpgradeState;
state.idleTimer?.cancel();
state.idleTimer = null;
unawaited(upgradeActiveSignalSessionsToV2());
}
/// Upgrades active V1 contacts that currently publish a PQXDH bundle.
/// ///
/// The legacy session is deliberately retained: messages that were already in /// The legacy session is deliberately retained: messages that were already in
/// flight before the upgrade can still be decrypted, while newly queued /// flight before the upgrade can still be decrypted, while newly queued
/// messages use the contact's updated v2 session. /// messages use the contact's updated v2 session.
Future<void> upgradeSignalSessionsToV2() async { Future<void> upgradeActiveSignalSessionsToV2() async {
if (_upgradeSignalSessionsLock.isLocked) return; final state = _signalUpgradeState;
try { try {
await _upgradeSignalSessionsLock.protect(() async { await state.lock.protect(() async {
final contacts = await twonlyDB.contactsDao.getAllContacts(); // Activity may arrive during an upgrade. Drain until every contact
final legacyContacts = contacts.where( // queued by that activity has been considered.
(contact) => while (state.activeV1Contacts.isNotEmpty) {
contact.signalVersion == SignalVersion.v1 && final contactIds = Set<int>.of(state.activeV1Contacts);
!contact.accountDeleted, state.activeV1Contacts.removeAll(contactIds);
);
for (final contact in legacyContacts) { for (final contactId in contactIds) {
try { try {
final userData = await apiService.getUserById(contact.userId); final contact = await twonlyDB.contactsDao.getContactById(
if (userData == null || !userData.hasPqcBundle()) continue; contactId,
);
if (contact == null ||
contact.signalVersion != SignalVersion.v1 ||
contact.accountDeleted) {
continue;
}
if (await processSignalUserData(userData)) { final userData = await apiService.getUserById(contactId);
Log.info('Upgraded Signal session with ${contact.userId} to v2.'); if (userData == null || !userData.hasPqcBundle()) continue;
if (await processSignalUserData(userData)) {
Log.info('Upgraded active Signal session with $contactId to v2.');
}
} catch (error) {
// Further traffic queues the contact again, so temporary failures
// cannot leave an active session on V1 indefinitely.
Log.warn(
'Could not upgrade active Signal session with $contactId: '
'$error',
);
} }
} catch (error) {
// Migration is best-effort. A contact that is temporarily
// unavailable remains on v1 and is retried after a later message
// queue drain.
Log.warn(
'Could not upgrade Signal session with ${contact.userId}: $error',
);
} }
} }
}); });
@ -76,7 +134,13 @@ Future<bool> resetSignalSession(int contactId) async {
await signalStore?.deleteAllSessions(contactId.toString()); await signalStore?.deleteAllSessions(contactId.toString());
} }
final reset = await _processSignalUserData(userData); final reset = await _processSignalUserData(userData);
if (reset) recordResyncAttempt(contactId, success: true); if (reset) {
recordResyncAttempt(
contactId,
userData.hasPqcBundle() ? SignalVersion.v2 : SignalVersion.v1,
success: true,
);
}
return reset; return reset;
}); });
} }

View file

@ -22,7 +22,7 @@ class Log {
record.level >= Level.WARNING) { record.level >= Level.WARNING) {
// ignore: avoid_print // ignore: avoid_print
print( print(
'${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}', '${record.level.name} [${_isolateTag(record)}] [twonly] ${record.loggerName} > ${record.message}',
); );
} }
} }
@ -77,6 +77,20 @@ class Log {
final message = filterLogMessage('$messageInput'); final message = filterLogMessage('$messageInput');
Logger(_getCallerSourceCodeFilename()).fine(message, error, stackTrace); Logger(_getCallerSourceCodeFilename()).fine(message, error, stackTrace);
} }
/// Logs a line forwarded from Rust.
static void rust(String message) {
Logger(_rustLoggerName).fine(filterLogMessage(message));
}
}
const _rustLoggerName = 'rust';
/// Rust forwards its logs to whichever isolate registered its log sink last,
/// so the receiving isolate says nothing about who caused a Rust line.
String _isolateTag(LogRecord record) {
if (record.loggerName == _rustLoggerName) return 'r';
return AppState.isInBackgroundTask ? 'b' : 'f';
} }
Future<String> loadLogFile() async { Future<String> loadLogFile() async {
@ -115,7 +129,7 @@ Future<void> _writeLogToFile(LogRecord record) async {
final logFile = File('${AppEnvironment.supportDir}/app.log'); final logFile = File('${AppEnvironment.supportDir}/app.log');
final logMessage = final logMessage =
'${clock.now()} ${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}\n'; '${clock.now()} ${record.level.name} [${_isolateTag(record)}] [twonly] ${record.loggerName} > ${record.message}\n';
return _protectFileAccess(() async { return _protectFileAccess(() async {
if (!logFile.existsSync()) { if (!logFile.existsSync()) {

View file

@ -94,7 +94,9 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
if (hasStored) { if (hasStored) {
unawaited( unawaited(
media.createThumbnail().then((_) { media.createThumbnail().then((_) {
if (mounted) { // Only a written thumbnail is worth resolving again. A video whose
// thumbnail cannot be created would otherwise retry forever.
if (mounted && media.thumbnailPath.existsSync()) {
_resolveImage(); _resolveImage();
} }
}), }),

View file

@ -333,7 +333,8 @@ class _LogEntry {
.trim() .trim()
.replaceAll('[twonly] ', '') .replaceAll('[twonly] ', '')
.replaceAll('[f] ', '') .replaceAll('[f] ', '')
.replaceAll('[b] ', ''); .replaceAll('[b] ', '')
.replaceAll('[r] ', '');
final fileNameS = msg.split(' > '); final fileNameS = msg.split(' > ');
final fileName = fileNameS[0]; final fileName = fileNameS[0];

View file

@ -70,6 +70,12 @@ pub enum TwonlyError {
#[error("Signal protocol error: {0}")] #[error("Signal protocol error: {0}")]
Signal(String), Signal(String),
/// libsignal refused a message whose counter was already used, so the
/// message was decrypted before and the session is intact. Dart matches
/// this text to tell it apart from a broken session.
#[error("Duplicated Signal message: {0}")]
DuplicatedSignalMessage(String),
} }
impl From<String> for TwonlyError { impl From<String> for TwonlyError {

View file

@ -3,7 +3,7 @@ use libsignal_protocol::{
message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey, message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey,
IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle, IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle,
PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SessionStore, PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SessionStore,
SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp, SignalMessage, SignalProtocolError, SignedPreKeyId, SignedPreKeyStore, Timestamp,
}; };
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@ -549,7 +549,7 @@ impl RustSignalEngine {
) )
.assert_send() .assert_send()
.await .await
.map_err(|e| TwonlyError::Signal(e.to_string()))? .map_err(decrypt_error)?
} }
CiphertextMessageType::PreKey => { CiphertextMessageType::PreKey => {
let message = PreKeySignalMessage::try_from(ciphertext) let message = PreKeySignalMessage::try_from(ciphertext)
@ -567,7 +567,7 @@ impl RustSignalEngine {
) )
.assert_send() .assert_send()
.await .await
.map_err(|e| TwonlyError::Signal(e.to_string()))? .map_err(decrypt_error)?
} }
_ => { _ => {
return Err(TwonlyError::Signal("Invalid message type".to_string())); return Err(TwonlyError::Signal("Invalid message type".to_string()));
@ -578,6 +578,15 @@ impl RustSignalEngine {
} }
} }
fn decrypt_error(error: SignalProtocolError) -> TwonlyError {
match error {
SignalProtocolError::DuplicatedMessage(..) => {
TwonlyError::DuplicatedSignalMessage(error.to_string())
}
error => TwonlyError::Signal(error.to_string()),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -706,4 +715,71 @@ mod tests {
.await .await
.is_ok()); .is_ok());
} }
#[tokio::test]
async fn decrypting_a_message_twice_reports_a_duplicate() {
let (alice_engine, _alice_dir) = create_test_engine("alice").await;
let (bob_engine, _bob_dir) = create_test_engine("bob").await;
alice_engine
.process_prekey_bundle(
"bob".to_string(),
1,
bob_engine.generate_bundle().await.unwrap(),
)
.await
.unwrap();
let prekey_message = alice_engine
.encrypt_message("bob".to_string(), 1, b"first".to_vec())
.await
.unwrap();
assert_eq!(
prekey_message.last(),
Some(&(CiphertextMessageType::PreKey as u8))
);
bob_engine
.decrypt_message("alice".to_string(), 1, prekey_message.clone())
.await
.unwrap();
let error = bob_engine
.decrypt_message("alice".to_string(), 1, prekey_message)
.await
.unwrap_err();
assert!(
matches!(error, TwonlyError::DuplicatedSignalMessage(_)),
"{error}"
);
// After Bob's reply Alice no longer needs a PreKey message.
let reply = bob_engine
.encrypt_message("alice".to_string(), 1, b"reply".to_vec())
.await
.unwrap();
alice_engine
.decrypt_message("bob".to_string(), 1, reply)
.await
.unwrap();
let whisper_message = alice_engine
.encrypt_message("bob".to_string(), 1, b"second".to_vec())
.await
.unwrap();
assert_eq!(
whisper_message.last(),
Some(&(CiphertextMessageType::Whisper as u8))
);
bob_engine
.decrypt_message("alice".to_string(), 1, whisper_message.clone())
.await
.unwrap();
let error = bob_engine
.decrypt_message("alice".to_string(), 1, whisper_message)
.await
.unwrap_err();
assert!(
matches!(error, TwonlyError::DuplicatedSignalMessage(_)),
"{error}"
);
assert!(error.to_string().starts_with("Duplicated Signal message"));
}
} }

View file

@ -0,0 +1,318 @@
import 'dart:async';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:fixnum/fixnum.dart';
import 'package:flutter/services.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hashlib/random.dart';
import 'package:logging/logging.dart';
import 'package:twonly/core/bridge.dart' as bridge;
import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/callbacks/callbacks.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
show NewMessage;
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb;
import 'package:twonly/src/services/api/api.service.dart';
import 'package:twonly/src/services/api/messages.api.dart';
import 'package:twonly/src/services/api/server_messages.api.dart';
import 'package:twonly/src/services/signal/protocol_state.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:workmanager/workmanager.dart';
import '../mocks/platform_channels.dart';
import '../mocks/test_client.dart';
import '../mocks/workmanager.dart';
/// Runs [action] as [client] and returns what it logged meanwhile.
Future<List<String>> logsOf(
TestClient client,
Future<void> Function() action,
) async {
final lines = <String>[];
final subscription = Logger.root.onRecord.listen((record) {
if (record.zone?[#userId] == client.env.userId) lines.add(record.message);
});
try {
await client.run(action);
return lines;
} finally {
await subscription.cancel();
}
}
/// A text from [sender] to [receiver] as the server would deliver it. It is
/// encrypted, but never sent.
Future<NewMessage> textFrom(
TestClient sender,
TestClient receiver,
String text,
) => sender.run(() async {
final (body, _) = (await sendCipherText(
receiver.realUserId,
pb.EncryptedContent(
groupId: sender.defaultGroup!.groupId,
textMessage: pb.EncryptedContent_TextMessage()
..senderMessageId = uuid.v4()
..text = text,
),
onlyReturnEncryptedData: true,
))!;
return NewMessage(body: body, fromUserId: Int64(sender.realUserId));
});
NewMessage withReceiptId(NewMessage message, String receiptId) => NewMessage(
body: (pb.Message.fromBuffer(
message.body,
)..receiptId = receiptId).writeToBuffer(),
fromUserId: message.fromUserId,
);
Future<T> later<T>(Duration elapsed, Future<T> Function() action) =>
withClock(Clock.fixed(DateTime.now().add(elapsed)), action);
void main() {
if (!Platform.isMacOS) {
return;
}
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUpAll(() async {
Logger.root.level = Level.ALL;
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
WorkmanagerPlatform.instance = MockWorkmanagerPlatform();
tempDir = Directory.systemTemp.createTempSync('twonly_duplicates_test_');
AppEnvironment.initTesting(
customCacheDir: tempDir.path,
customSupportDir: tempDir.path,
);
final dylibPath =
'${Directory.current.path}/rust/target/debug/librust_lib_twonly.dylib';
if (File(dylibPath).existsSync()) {
await RustLib.init(externalLibrary: ExternalLibrary.open(dylibPath));
} else {
await RustLib.init();
}
await initFlutterCallbacksForRust();
await bridge.initializeTwonlyFlutter(
config: bridge.InitConfig(
databaseDir: tempDir.path,
dataDir: tempDir.path,
),
);
if (locator.isRegistered<TwonlyDB>()) await locator.unregister<TwonlyDB>();
if (locator.isRegistered<UserService>()) {
await locator.unregister<UserService>();
}
if (locator.isRegistered<ApiService>()) {
await locator.unregister<ApiService>();
}
locator
..registerFactory<TwonlyDB>(() {
final db = Zone.current[#twonlyDB] as TwonlyDB?;
if (db != null) return db;
throw StateError('No TwonlyDB in active Zone.');
})
..registerFactory<UserService>(() {
final us = Zone.current[#userService] as UserService?;
if (us != null) return us;
throw StateError('No UserService in active Zone.');
})
..registerFactory<ApiService>(() {
final api = Zone.current[#apiService] as ApiService?;
if (api != null) return api;
throw StateError('No ApiService in active Zone.');
});
});
tearDownAll(() async {
if (tempDir.existsSync()) {
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
}
});
// Alice sends, Bob receives.
late TestClient alice;
late TestClient bob;
setUp(() async {
setupPlatformChannelMocks();
HttpOverrides.global = RealHttpOverrides();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/package_info'),
(call) async {
return {
'appName': 'twonly',
'packageName': 'eu.twonly.app',
'version': '1.0.0',
'buildNumber': '100',
};
},
);
await Workmanager().initialize(() {});
bob = TestClient(7003);
alice = TestClient(8003);
await bob.init(disablePqc: true);
await alice.init(disablePqc: true);
await alice.initContact(bob);
await bob.initContact(alice);
});
tearDown(() async {
await alice.run(() async => alice.api.close(null));
await bob.run(() async => bob.api.close(null));
await alice.env.db.close();
await bob.env.db.close();
});
group('Duplicate messages', () {
setUp(() async {
// Acknowledged sessions, so Alice sends plain Whisper messages.
await alice.sendText(bob, 'hello');
await bob.expectMessage((m) => m.content == 'hello');
await bob.sendText(alice, 'hi');
await alice.expectMessage((m) => m.content == 'hi');
});
test('a duplicate is confirmed again once the cooldown ended', () async {
final message = await textFrom(alice, bob, 'once');
await bob.run(() => handleClient2ClientMessage(message));
await bob.expectMessage((m) => m.content == 'once');
final duringCooldown = await logsOf(
bob,
() => handleClient2ClientMessage(message),
);
expect(
duringCooldown,
contains(contains('Skipping delivery receipt during cooldown')),
);
final afterCooldown = await logsOf(
bob,
() => later(
const Duration(hours: 2),
() => handleClient2ClientMessage(message),
),
);
expect(
afterCooldown,
contains(contains('Sending delivery receipt again')),
);
});
test(
'a message that could not be decrypted is read when it comes again',
() async {
final first = await textFrom(alice, bob, 'first');
await bob.run(() => handleClient2ClientMessage(first));
await bob.expectMessage((m) => m.content == 'first');
// The same ciphertext under a new receipt id cannot be decrypted
// twice, so Bob answers it with a decryption error.
final receiptId = uuid.v4();
final logs = await logsOf(
bob,
() => handleClient2ClientMessage(withReceiptId(first, receiptId)),
);
expect(logs, contains(contains('Received message with old counter')));
expect(
await bob.run(() => twonlyDB.receiptsDao.isDuplicated(receiptId)),
isFalse,
);
// Alice resends it, and this time it can be decrypted.
final resent = withReceiptId(
await textFrom(alice, bob, 'resent'),
receiptId,
);
await bob.run(() => handleClient2ClientMessage(resent));
await bob.expectMessage((m) => m.content == 'resent');
},
);
test('a duplicate reply is never answered', () async {
final decryptionError = NewMessage(
body: pb.Message(
receiptId: uuid.v4(),
type: pb.Message_Type.PLAINTEXT_CONTENT,
plaintextContent: pb.PlaintextContent(
decryptionErrorMessage: pb.PlaintextContent_DecryptionErrorMessage(
type: pb.PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN,
),
),
).writeToBuffer(),
fromUserId: Int64(alice.realUserId),
);
await bob.run(() => handleClient2ClientMessage(decryptionError));
final logs = await logsOf(
bob,
() => later(
const Duration(days: 11),
() => handleClient2ClientMessage(decryptionError),
),
);
expect(logs, contains(contains('Unencrypted message is a duplicate')));
expect(logs, isNot(contains(contains('Sending delivery receipt again'))));
});
});
group('Lost sessions', () {
setUp(
() => alice.run(() async {
await (await getSignalStore())!.deleteAllSessions(
bob.realUserId.toString(),
);
}),
);
test('a message without a session builds one and arrives', () async {
await alice.sendText(bob, 'after the session was lost');
await bob.expectMessage((m) => m.content == 'after the session was lost');
});
test('a message that cannot be encrypted waits for a retry', () async {
// The resync limiter refuses to rebuild the session for now.
for (var i = 0; i < maxResyncAttempts; i++) {
recordResyncAttempt(
bob.realUserId,
SignalVersion.v1,
success: false,
);
}
final message = await alice.sendText(bob, 'waiting');
final receipts = await alice.run(
() => twonlyDB.receiptsDao.getReceiptsByContactAndMessageId(
bob.realUserId,
message.messageId,
),
);
expect(receipts, hasLength(1));
expect(receipts.single.ackByServerAt, isNull);
recordResyncAttempt(bob.realUserId, SignalVersion.v1, success: true);
await alice.run(retransmitAllMessages);
await bob.expectMessage((m) => m.content == 'waiting');
});
});
}

View file

@ -0,0 +1,66 @@
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/messages.api.dart';
void main() {
final lastRetry = DateTime(2026, 9, 24, 12);
Receipt receipt(int retryCount) => Receipt(
receiptId: 'receipt',
contactId: 1,
message: Uint8List(0),
contactWillSendsReceipt: true,
willBeRetriedByMediaUpload: false,
ackByServerAt: lastRetry,
retryCount: retryCount,
lastRetry: lastRetry,
createdAt: lastRetry,
);
bool dueAfter(
Duration elapsed,
Receipt receipt, {
Duration minimumPause = Duration.zero,
}) => withClock(
Clock.fixed(lastRetry.add(elapsed)),
() => isRetransmissionDue(receipt, minimumPause: minimumPause),
);
test('the first retry is not held back', () {
expect(dueAfter(Duration.zero, receipt(0)), isTrue);
expect(dueAfter(Duration.zero, receipt(1)), isTrue);
});
test('the pause doubles from one hour with every attempt', () {
for (final (retryCount, hours) in [(2, 1), (3, 2), (4, 4), (6, 16)]) {
final pause = Duration(hours: hours);
final r = receipt(retryCount);
expect(
dueAfter(pause - const Duration(minutes: 1), r),
isFalse,
reason: 'retry $retryCount',
);
expect(dueAfter(pause, r), isTrue, reason: 'retry $retryCount');
}
});
test('the pause stops growing at one day', () {
final stuck = receipt(65);
expect(dueAfter(const Duration(hours: 23), stuck), isFalse);
expect(dueAfter(const Duration(hours: 24), stuck), isTrue);
});
test('the pause never drops below the minimum', () {
const sixHours = Duration(hours: 6);
final r = receipt(2);
expect(
dueAfter(const Duration(hours: 5), r, minimumPause: sixHours),
isFalse,
);
expect(dueAfter(sixHours, r, minimumPause: sixHours), isTrue);
// The first retry is not held back by the minimum either.
expect(dueAfter(Duration.zero, receipt(1), minimumPause: sixHours), isTrue);
});
}

View file

@ -0,0 +1,403 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' hide isNotNull, isNull;
import 'package:fixnum/fixnum.dart';
import 'package:flutter/services.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hashlib/random.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'
show CiphertextMessage;
import 'package:logging/logging.dart';
import 'package:twonly/core/bridge.dart' as bridge;
import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/callbacks/callbacks.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
show NewMessage;
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb;
import 'package:twonly/src/services/api/api.service.dart';
import 'package:twonly/src/services/api/server_messages.api.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:workmanager/workmanager.dart';
import '../mocks/platform_channels.dart';
import '../mocks/test_client.dart';
import '../mocks/workmanager.dart';
// Every client in this process shares one Rust Signal engine, whose identity
// is the one imported last. A contact can therefore only move from V1 to V2
// by processing the bundle of the client that was initialized last.
Future<void> uploadPqcKeys(TestClient client) => client.run(() async {
await UserService.update((user) {
user.signalLastPqcPreKeysUploaded = null;
});
await SignalIdentityService.onAuthenticated();
});
Future<SignalVersion?> versionOf(TestClient client, TestClient contact) =>
client.run(
() async => (await client.env.db.contactsDao.getContactById(
contact.realUserId,
))?.signalVersion,
);
/// Runs [action] as [client] and returns what it logged meanwhile.
Future<(T, List<String>)> logsOf<T>(
TestClient client,
Future<T> Function() action,
) async {
final lines = <String>[];
final subscription = Logger.root.onRecord.listen((record) {
if (record.zone?[#userId] == client.env.userId) lines.add(record.message);
});
try {
return (await client.run(action), lines);
} finally {
await subscription.cancel();
}
}
Uint8List textContent(String text) => pb.EncryptedContent(
textMessage: pb.EncryptedContent_TextMessage()..text = text,
).writeToBuffer();
/// [sender] encrypts messages over V1 that never reach [receiver], until its
/// sending chain is further ahead than [receiver] can follow.
Future<void> breakV1Chain(TestClient sender, TestClient receiver) =>
sender.run(() async {
for (var i = 0; i < 2002; i++) {
await signalEncryptMessage(receiver.realUserId, textContent('lost $i'));
}
});
void main() {
if (!Platform.isMacOS) {
return;
}
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUpAll(() async {
Logger.root.level = Level.ALL;
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
WorkmanagerPlatform.instance = MockWorkmanagerPlatform();
tempDir = Directory.systemTemp.createTempSync('twonly_recovery_test_');
AppEnvironment.initTesting(
customCacheDir: tempDir.path,
customSupportDir: tempDir.path,
);
final dylibPath =
'${Directory.current.path}/rust/target/debug/librust_lib_twonly.dylib';
if (File(dylibPath).existsSync()) {
await RustLib.init(externalLibrary: ExternalLibrary.open(dylibPath));
} else {
await RustLib.init();
}
await initFlutterCallbacksForRust();
await bridge.initializeTwonlyFlutter(
config: bridge.InitConfig(
databaseDir: tempDir.path,
dataDir: tempDir.path,
),
);
if (locator.isRegistered<TwonlyDB>()) await locator.unregister<TwonlyDB>();
if (locator.isRegistered<UserService>()) {
await locator.unregister<UserService>();
}
if (locator.isRegistered<ApiService>()) {
await locator.unregister<ApiService>();
}
locator
..registerFactory<TwonlyDB>(() {
final db = Zone.current[#twonlyDB] as TwonlyDB?;
if (db != null) return db;
throw StateError('No TwonlyDB in active Zone.');
})
..registerFactory<UserService>(() {
final us = Zone.current[#userService] as UserService?;
if (us != null) return us;
throw StateError('No UserService in active Zone.');
})
..registerFactory<ApiService>(() {
final api = Zone.current[#apiService] as ApiService?;
if (api != null) return api;
throw StateError('No ApiService in active Zone.');
});
});
tearDownAll(() async {
if (tempDir.existsSync()) {
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
}
});
group('Signal session recovery', () {
// Alice sends, Bob receives. Bob is always initialized first, so the
// shared Rust identity is Alice's.
late TestClient alice;
late TestClient bob;
setUp(() async {
setupPlatformChannelMocks();
HttpOverrides.global = RealHttpOverrides();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/package_info'),
(call) async {
return {
'appName': 'twonly',
'packageName': 'eu.twonly.app',
'version': '1.0.0',
'buildNumber': '100',
};
},
);
await Workmanager().initialize(() {});
bob = TestClient(7002);
alice = TestClient(8001);
await bob.init(disablePqc: true);
await alice.init(disablePqc: true);
});
tearDown(() async {
await alice.run(() async => alice.api.close(null));
await bob.run(() async => bob.api.close(null));
await alice.env.db.close();
await bob.env.db.close();
});
/// Both sides talk V1 and Alice's session is acknowledged, so she sends
/// plain Whisper messages. Alice then publishes a V2 bundle.
Future<void> talkOverV1ThenPublishV2({bool letBobSettle = false}) async {
await alice.initContact(bob);
await bob.initContact(alice);
await alice.sendText(bob, 'hello over V1');
await bob.expectMessage((m) => m.content == 'hello over V1');
await bob.sendText(alice, 'reply over V1');
await alice.expectMessage((m) => m.content == 'reply over V1');
if (letBobSettle) {
// Bob upgrades V1 contacts on his own shortly after messages arrive.
// Let that happen while Alice has no V2 bundle to upgrade to.
await Future<void>.delayed(const Duration(seconds: 2));
}
await uploadPqcKeys(alice);
}
test('a replayed V2 message leaves the session alone', () async {
await uploadPqcKeys(alice);
await uploadPqcKeys(bob);
await alice.initContact(bob);
await bob.initContact(alice);
expect(await versionOf(bob, alice), SignalVersion.v2);
final ciphertext = (await alice.run(
() => signalEncryptMessageV2(bob.realUserId, textContent('once')),
))!.ciphertext;
final (first, _) = await bob.run(
() => signalDecryptMessageV2(alice.realUserId, ciphertext),
);
expect(first!.textMessage.text, 'once');
final ((replayed, errorType), logs) = await logsOf(
bob,
() => signalDecryptMessageV2(alice.realUserId, ciphertext),
);
expect(replayed, isNull);
expect(
errorType,
pb.PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN,
);
expect(logs, isNot(contains(contains('re-sync the session'))));
expect(logs, isNot(contains(contains('Could not decrypt message'))));
});
test('a V2 message moves a V1 contact to V2', () async {
await uploadPqcKeys(bob);
await alice.initContact(bob);
await bob.initContact(alice);
expect(await versionOf(alice, bob), SignalVersion.v2);
expect(await versionOf(bob, alice), SignalVersion.v1);
final ciphertext = (await alice.run(
() => signalEncryptMessageV2(bob.realUserId, textContent('over V2')),
))!.ciphertext;
final (content, _) = await bob.run(
() => signalDecryptMessageV2(alice.realUserId, ciphertext),
);
expect(content!.textMessage.text, 'over V2');
expect(await versionOf(bob, alice), SignalVersion.v2);
});
test(
'a dead V1 chain asks the sender to rebuild, not us to reset V2',
() async {
await talkOverV1ThenPublishV2();
await bob.run(() async {
final userData = await bob.api.getUserById(alice.realUserId);
expect(await processSignalUserData(userData!), isTrue);
});
expect(await versionOf(bob, alice), SignalVersion.v2);
await breakV1Chain(alice, bob);
final encrypted = (await alice.run(
() => signalEncryptMessage(bob.realUserId, textContent('stuck')),
))!;
expect(encrypted.type, pb.Message_Type.CIPHERTEXT);
final ((content, errorType), logs) = await logsOf(
bob,
() => signalDecryptMessageV1(
alice.realUserId,
encrypted.ciphertext,
CiphertextMessage.whisperType,
),
);
expect(content, isNull);
expect(
errorType,
pb.PlaintextContent_DecryptionErrorMessage_Type.PREKEY_UNKNOWN,
);
expect(logs, contains(contains('beyond the receiving window')));
expect(logs, isNot(contains(contains('re-sync the session'))));
},
timeout: const Timeout(Duration(minutes: 2)),
);
test(
'a V1 resync that moves us to V2 asks the sender to rebuild as well',
() async {
await talkOverV1ThenPublishV2(letBobSettle: true);
expect(await versionOf(bob, alice), SignalVersion.v1);
await breakV1Chain(alice, bob);
final encrypted = (await alice.run(
() => signalEncryptMessage(bob.realUserId, textContent('stuck')),
))!;
final ((content, errorType), logs) = await logsOf(
bob,
() => signalDecryptMessageV1(
alice.realUserId,
encrypted.ciphertext,
CiphertextMessage.whisperType,
),
);
expect(content, isNull);
expect(
errorType,
pb.PlaintextContent_DecryptionErrorMessage_Type.PREKEY_UNKNOWN,
);
expect(logs, contains(contains('re-sync the session')));
expect(await versionOf(bob, alice), SignalVersion.v2);
},
timeout: const Timeout(Duration(minutes: 2)),
);
test(
'a broken V1 session does not hold back V2 messages of the same batch',
() async {
await talkOverV1ThenPublishV2();
await bob.run(() async {
final userData = await bob.api.getUserById(alice.realUserId);
expect(await processSignalUserData(userData!), isTrue);
});
// Bob's first V2 message gives Alice a V2 session to answer with.
await bob.sendText(alice, 'hello over V2');
await alice.expectMessage((m) => m.content == 'hello over V2');
await breakV1Chain(alice, bob);
Future<NewMessage> fromAlice(SignalEncryptResult encrypted) async =>
NewMessage(
body: pb.Message(
receiptId: uuid.v4(),
type: encrypted.type,
encryptedContent: encrypted.ciphertext,
).writeToBuffer(),
fromUserId: Int64(alice.realUserId),
);
Uint8List text(String text) => pb.EncryptedContent(
groupId: alice.defaultGroup!.groupId,
textMessage: pb.EncryptedContent_TextMessage()
..senderMessageId = uuid.v4()
..text = text,
).writeToBuffer();
final overDeadV1Chain = await alice.run(
() async => fromAlice(
(await signalEncryptMessage(
bob.realUserId,
text('over the dead V1 chain'),
))!,
),
);
final overV2 = await alice.run(
() async => fromAlice(
(await signalEncryptMessageV2(
bob.realUserId,
text('over V2 in the same batch'),
))!,
),
);
final brokenSessions = <(int, SignalVersion)>{};
await bob.run(() async {
for (final message in [overDeadV1Chain, overV2]) {
await handleClient2ClientMessage(
message,
brokenSessionsInCurrentBatch: brokenSessions,
);
}
});
expect(brokenSessions, {(alice.realUserId, SignalVersion.v1)});
await bob.expectMessage(
(m) => m.content == 'over V2 in the same batch',
);
},
timeout: const Timeout(Duration(minutes: 2)),
);
test(
'a message sent over a dead V1 chain still arrives',
() async {
await talkOverV1ThenPublishV2();
await bob.run(() async {
final userData = await bob.api.getUserById(alice.realUserId);
expect(await processSignalUserData(userData!), isTrue);
});
await breakV1Chain(alice, bob);
await alice.sendText(bob, 'sent over the dead chain');
final received = await bob.expectMessage(
(m) => m.content == 'sent over the dead chain',
);
expect(received.senderId, alice.realUserId);
},
timeout: const Timeout(Duration(minutes: 2)),
);
});
}

View file

@ -10,6 +10,7 @@ import 'package:twonly/core/bridge.dart' as bridge;
import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/core/bridge/wrapper/signal.dart';
import 'package:twonly/core/frb_generated.dart'; import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/core/signal/engine.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/callbacks/callbacks.dart'; import 'package:twonly/src/callbacks/callbacks.dart';
@ -19,11 +20,45 @@ import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart
as api_pb; as api_pb;
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as msg_pb; as msg_pb;
import 'package:twonly/src/services/api/api.service.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
class _MigrationApiService extends ApiService {
final Map<int, api_pb.Response_UserData> users = {};
final List<int> requestedUserIds = [];
@override
Future<api_pb.Response_UserData?> getUserById(int userId) async {
requestedUserIds.add(userId);
return users[userId];
}
}
api_pb.Response_UserData _userDataFromBundle(
int userId,
FrbPreKeyBundle bundle,
) => api_pb.Response_UserData(
userId: Int64(userId),
username: utf8.encode('contact_$userId'),
registrationId: Int64(bundle.registrationId),
publicIdentityKey: bundle.identityKey,
pqcBundle: api_pb.Response_PqcBundle(
prekey: api_pb.Response_PqcPreKey(
eccPreKeyId: bundle.preKeyId != null ? Int64(bundle.preKeyId!) : null,
eccPreKey: bundle.preKeyPublic,
kyberPreKeyId: Int64(bundle.kyberPreKeyId),
kyberPreKey: bundle.kyberPreKeyPublic,
kyberPreKeySignature: bundle.kyberPreKeySignature,
),
eccSignedPrekeyId: Int64(bundle.signedPreKeyId),
eccSignedPrekey: bundle.signedPreKeyPublic,
eccSignedPrekeySignature: bundle.signedPreKeySignature,
),
);
void main() { void main() {
if (!Platform.isMacOS) { if (!Platform.isMacOS) {
return; return;
@ -32,6 +67,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir; late Directory tempDir;
late TwonlyDB db; late TwonlyDB db;
late _MigrationApiService api;
setUpAll(() async { setUpAll(() async {
Log.init(); Log.init();
@ -59,10 +95,16 @@ void main() {
); );
db = TwonlyDB(NativeDatabase.memory()); db = TwonlyDB(NativeDatabase.memory());
locator.registerFactory<TwonlyDB>(() => db); api = _MigrationApiService();
locator
..registerFactory<TwonlyDB>(() => db)
..registerSingleton<ApiService>(api);
}); });
setUp(() async { setUp(() async {
api
..users.clear()
..requestedUserIds.clear();
await createIfNotExistsSignalIdentity(); await createIfNotExistsSignalIdentity();
await RustKeyManager.setUserId(userId: 1); await RustKeyManager.setUserId(userId: 1);
}); });
@ -179,4 +221,39 @@ void main() {
expect(encryptedResult!.type, msg_pb.Message_Type.CIPHERTEXT_V2); expect(encryptedResult!.type, msg_pb.Message_Type.CIPHERTEXT_V2);
expect(encryptedResult.ciphertext, isNotEmpty); expect(encryptedResult.ciphertext, isNotEmpty);
}); });
test('only active V1 sessions are upgraded', () async {
const activeContactIds = [701, 702];
const inactiveContactId = 703;
for (final contactId in [...activeContactIds, inactiveContactId]) {
await db.contactsDao.insertContact(
ContactsCompanion.insert(
userId: Value(contactId),
username: 'contact_$contactId',
accepted: const Value(true),
signalVersion: const Value(SignalVersion.v1),
),
);
api.users[contactId] = _userDataFromBundle(
contactId,
await RustSignal.generateBundle(),
);
}
activeContactIds.forEach(scheduleSignalSessionUpgrade);
await upgradeActiveSignalSessionsToV2();
for (final contactId in activeContactIds) {
expect(
(await db.contactsDao.getContactById(contactId))!.signalVersion,
SignalVersion.v2,
);
}
expect(
(await db.contactsDao.getContactById(inactiveContactId))!.signalVersion,
SignalVersion.v1,
);
expect(api.requestedUserIds, unorderedEquals(activeContactIds));
});
} }

View file

@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/services/signal/protocol_state.signal.dart';
void main() {
test('V1 recovery backoff does not disable V2 recovery', () {
const contactId = 912345;
for (var i = 0; i < maxResyncAttempts; i++) {
recordResyncAttempt(contactId, SignalVersion.v1, success: false);
}
expect(shouldAttemptResync(contactId, SignalVersion.v1), isFalse);
expect(shouldAttemptResync(contactId, SignalVersion.v2), isTrue);
recordResyncAttempt(contactId, SignalVersion.v1, success: true);
recordResyncAttempt(contactId, SignalVersion.v2, success: true);
});
}

View file

@ -0,0 +1,158 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:libsignal_protocol_dart/src/invalid_message_exception.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart';
class _Party {
_Party(this.name)
: store = InMemorySignalProtocolStore(
generateIdentityKeyPair(),
generateRegistrationId(false),
);
final String name;
final InMemorySignalProtocolStore store;
var _nextKeyId = 1;
SignalProtocolAddress get address => SignalProtocolAddress(name, 1);
SessionCipher cipherFor(_Party other) =>
SessionCipher.fromStore(store, other.address);
Future<PreKeyBundle> newBundle() async {
final keyId = _nextKeyId++;
final preKey = generatePreKeys(keyId, 1).single;
await store.storePreKey(preKey.id, preKey);
final identity = await store.getIdentityKeyPair();
final signedPreKey = generateSignedPreKey(identity, keyId);
await store.storeSignedPreKey(signedPreKey.id, signedPreKey);
return PreKeyBundle(
await store.getLocalRegistrationId(),
1,
preKey.id,
preKey.getKeyPair().publicKey,
signedPreKey.id,
signedPreKey.getKeyPair().publicKey,
signedPreKey.signature,
identity.getPublicKey(),
);
}
/// Starts a new session with [other] and has [other] accept it.
Future<void> startSessionWith(_Party other) async {
await SessionBuilder.fromSignalStore(
store,
other.address,
).processPreKeyBundle(await other.newBundle());
final first = await cipherFor(other).encrypt(_text('first'));
await other.cipherFor(this).decrypt(PreKeySignalMessage(first.serialize()));
}
}
Uint8List _text(String text) => Uint8List.fromList(utf8.encode(text));
/// Alice encrypts [count] messages that never reach Bob.
Future<void> _loseMessages(SessionCipher aliceCipher, int count) async {
for (var i = 0; i < count; i++) {
await aliceCipher.encrypt(_text('lost $i'));
}
}
Future<SignalMessage> _whisper(SessionCipher cipher, String text) async =>
SignalMessage.fromSerialized(
(await cipher.encrypt(_text(text))).serialize(),
);
void main() {
late _Party alice;
late _Party bob;
late SessionCipher aliceCipher;
late SessionCipher bobCipher;
setUp(() async {
alice = _Party('alice');
bob = _Party('bob');
await alice.startSessionWith(bob);
aliceCipher = alice.cipherFor(bob);
bobCipher = bob.cipherFor(alice);
// Bob's reply makes Alice ratchet to a sending chain Bob has not seen.
final reply = await bobCipher.encrypt(_text('reply'));
await aliceCipher.decryptFromSignal(
SignalMessage.fromSerialized(reply.serialize()),
);
});
test('accepts a message on a working chain', () async {
final message = await _whisper(aliceCipher, 'hello');
final record = await bob.store.loadSession(alice.address);
expect(isBeyondReceivingWindow(record, message), isFalse);
expect(utf8.decode(await bobCipher.decryptFromSignal(message)), 'hello');
});
test('agrees with libsignal at the edge of the receiving window', () async {
await _loseMessages(aliceCipher, 2000);
final atEdge = await _whisper(aliceCipher, 'counter 2000');
final beyond = await _whisper(aliceCipher, 'counter 2001');
expect(atEdge.getCounter(), 2000);
final record = await bob.store.loadSession(alice.address);
expect(isBeyondReceivingWindow(record, atEdge), isFalse);
expect(isBeyondReceivingWindow(record, beyond), isTrue);
await expectLater(
bobCipher.decryptFromSignal(beyond),
throwsA(isA<InvalidMessageException>()),
);
expect(
utf8.decode(await bobCipher.decryptFromSignal(atEdge)),
'counter 2000',
);
});
test('checks archived session states as well', () async {
await _loseMessages(aliceCipher, 2000);
final delivered = await _whisper(aliceCipher, 'counter 2000');
final heldBack = await _whisper(aliceCipher, 'counter 2001');
await _loseMessages(aliceCipher, 2000);
final farAhead = await _whisper(aliceCipher, 'counter 4002');
expect(farAhead.getCounter(), 4002);
// Bob's chain for these messages now continues at 2001.
await bobCipher.decryptFromSignal(delivered);
// A new session from Alice turns that chain's state into an archived one.
await alice.startSessionWith(bob);
final record = await bob.store.loadSession(alice.address);
expect(record.previousSessionStates, hasLength(1));
// Too far ahead for a new chain of the current state, but next in line
// for the archived one.
expect(isBeyondReceivingWindow(record, heldBack), isFalse);
// Too far ahead for both.
expect(isBeyondReceivingWindow(record, farAhead), isTrue);
await expectLater(
bobCipher.decryptFromSignal(farAhead),
throwsA(isA<InvalidMessageException>()),
);
expect(
utf8.decode(await bobCipher.decryptFromSignal(heldBack)),
'counter 2001',
);
});
test('leaves a missing session to libsignal', () async {
final message = await _whisper(aliceCipher, 'hello');
// Without a session there is no state to judge, libsignal reports the
// missing session itself.
final record = await bob.store.loadSession(
const SignalProtocolAddress('carol', 1),
);
expect(isBeyondReceivingWindow(record, message), isFalse);
});
}

55
test/utils/log_test.dart Normal file
View file

@ -0,0 +1,55 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/log.dart';
void main() {
late Directory dir;
setUpAll(() {
dir = Directory.systemTemp.createTempSync('twonly_log_test_');
AppEnvironment.initTesting(
customCacheDir: dir.path,
customSupportDir: dir.path,
);
Log.init();
});
tearDownAll(() => dir.deleteSync(recursive: true));
Future<List<String>> linesContaining(List<String> needles) async {
for (var i = 0; i < 100; i++) {
final lines = (await loadLogFile()).split('\n');
final found = [
for (final needle in needles)
lines.where((line) => line.contains(needle)).firstOrNull,
];
if (!found.contains(null)) return found.cast<String>();
await Future<void>.delayed(const Duration(milliseconds: 10));
}
fail('The log file never contained all of $needles');
}
test(
'tags lines forwarded from Rust instead of the receiving isolate',
() async {
Log.rust('libsignal_protocol::session_management: forwarded line');
Log.info('line from Dart');
final [rustLine, dartLine] = await linesContaining([
'forwarded line',
'line from Dart',
]);
expect(
rustLine,
endsWith(
'FINE [r] [twonly] rust > '
'libsignal_protocol::session_management: forwarded line',
),
);
expect(dartLine, contains('FINE [f] [twonly] '));
},
);
}

View file

@ -0,0 +1,81 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
void main() {
late Directory supportDir;
var thumbnailRequests = 0;
setUp(() {
supportDir = Directory.systemTemp.createTempSync('twonly_thumbnail_test_');
AppEnvironment.initTesting(
customCacheDir: supportDir.path,
customSupportDir: supportDir.path,
);
thumbnailRequests = 0;
// Stands in for a video the platform cannot create a thumbnail for.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(const MethodChannel('pro_video_editor'), (
call,
) async {
if (call.method != 'getThumbnails') return null;
thumbnailRequests++;
await Future<void>.delayed(const Duration(milliseconds: 50));
return <Uint8List>[];
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('pro_video_editor'),
null,
);
supportDir.deleteSync(recursive: true);
});
testWidgets('does not retry a video thumbnail that cannot be created', (
tester,
) async {
final media = MediaFileService(
MediaFile(
mediaId: 'video-without-thumbnail',
type: MediaType.video,
cloudState: CloudState.none,
requiresAuthentication: false,
stored: true,
isDraftMedia: false,
isFavorite: false,
hasCropAnalyzed: false,
hasThumbnail: false,
createdAt: DateTime(2026, 9, 24),
),
);
media.storedPath.writeAsBytesSync([1, 2, 3]);
await tester.pumpWidget(
MaterialApp(
home: SizedBox(
width: 120,
height: 200,
child: MemoriesThumbnailComp(
galleryItem: MemoryItem(mediaService: media, messages: []),
onTap: () {},
),
),
),
);
await tester.pump(const Duration(seconds: 2));
expect(thumbnailRequests, 1);
expect(media.thumbnailPath.existsSync(), isFalse);
});
}