Automatically recover from bad encrypted messages.

This commit is contained in:
Greyson Parrelli
2021-01-08 10:23:46 -05:00
parent adea15df10
commit 728f1707b6
22 changed files with 331 additions and 51 deletions

View File

@@ -65,6 +65,7 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
implements SharedPreferences.OnSharedPreferenceChangeListener
{
public static final String LAUNCH_TO_BACKUPS_FRAGMENT = "launch.to.backups.fragment";
public static final String LAUNCH_TO_HELP_FRAGMENT = "launch.to.help.fragment";
@SuppressWarnings("unused")
private static final String TAG = ApplicationPreferencesActivity.class.getSimpleName();
@@ -104,6 +105,8 @@ public class ApplicationPreferencesActivity extends PassphraseRequiredActivity
initFragment(android.R.id.content, new NotificationsPreferenceFragment());
} else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_BACKUPS_FRAGMENT, false)) {
initFragment(android.R.id.content, new BackupsPreferenceFragment());
} else if (getIntent() != null && getIntent().getBooleanExtra(LAUNCH_TO_HELP_FRAGMENT, false)) {
initFragment(android.R.id.content, new HelpFragment());
} else if (icicle == null) {
initFragment(android.R.id.content, new ApplicationPreferenceFragment());
} else {

View File

@@ -61,6 +61,7 @@ public interface BindableConversationItem extends Unbindable {
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
void onDecryptionFailedLearnMoreClicked();
void onJoinGroupCallClicked();
void onInviteFriendsToGroupClicked(@NonNull GroupId.V2 groupId);

View File

@@ -66,6 +66,7 @@ import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
@@ -1415,6 +1416,23 @@ public class ConversationFragment extends LoggingFragment {
GroupsV1MigrationInfoBottomSheetDialogFragment.show(requireFragmentManager(), membershipChange);
}
@Override
public void onDecryptionFailedLearnMoreClicked() {
new AlertDialog.Builder(requireContext())
.setView(R.layout.decryption_failed_dialog)
.setPositiveButton(android.R.string.ok, (d, w) -> {
d.dismiss();
})
.setNeutralButton(R.string.ConversationFragment_contact_us, (d, w) -> {
Intent intent = new Intent(requireContext(), ApplicationPreferencesActivity.class);
intent.putExtra(ApplicationPreferencesActivity.LAUNCH_TO_HELP_FRAGMENT, true);
startActivity(intent);
d.dismiss();
})
.show();
}
@Override
public void onJoinGroupCallClicked() {
CommunicationActions.startVideoCall(requireActivity(), recipient.get());

View File

@@ -206,6 +206,16 @@ public final class ConversationUpdateItem extends LinearLayout
eventListener.onGroupMigrationLearnMoreClicked(conversationMessage.getMessageRecord().getGroupV1MigrationMembershipChanges());
}
});
} else if (conversationMessage.getMessageRecord().isFailedDecryptionType() &&
(!nextMessageRecord.isPresent() || !nextMessageRecord.get().isFailedDecryptionType()))
{
actionButton.setText(R.string.ConversationUpdateItem_learn_more);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null) {
eventListener.onDecryptionFailedLearnMoreClicked();
}
});
} else if (conversationMessage.getMessageRecord().isGroupCall()) {
UpdateDescription updateDescription = MessageRecord.getGroupCallUpdateDescription(getContext(), conversationMessage.getMessageRecord().getBody(), true);
Collection<UUID> uuids = updateDescription.getMentioned();

View File

@@ -434,7 +434,8 @@ public final class ConversationListItem extends ConstraintLayout
} else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.ConversationListItem_key_exchange_message), defaultTint);
} else if (SmsDatabase.Types.isFailedDecryptType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.MessageDisplayHelper_bad_encrypted_message), defaultTint);
UpdateDescription description = UpdateDescription.staticDescription(context.getString(R.string.ThreadRecord_chat_session_refreshed), R.drawable.ic_refresh_16);
return emphasisAdded(context, description, defaultTint);
} else if (SmsDatabase.Types.isNoRemoteSessionType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session), defaultTint);
} else if (SmsDatabase.Types.isEndSessionType(thread.getType())) {

View File

@@ -29,4 +29,7 @@ public class SessionUtil {
new TextSecureSessionStore(context).archiveAllSessions();
}
public static void archiveSession(Context context, RecipientId recipientId, int deviceId) {
new TextSecureSessionStore(context).archiveSession(recipientId, deviceId);
}
}

View File

@@ -103,6 +103,16 @@ public class TextSecureSessionStore implements SessionStore {
}
}
public void archiveSession(@NonNull RecipientId recipientId, int deviceId) {
synchronized (FILE_LOCK) {
SessionRecord session = DatabaseFactory.getSessionDatabase(context).load(recipientId, deviceId);
if (session != null) {
session.archiveCurrentState();
DatabaseFactory.getSessionDatabase(context).store(recipientId, deviceId, session);
}
}
}
public void archiveSiblingSessions(@NonNull SignalProtocolAddress address) {
synchronized (FILE_LOCK) {
if (DatabaseFactory.getRecipientDatabase(context).containsPhoneOrUuid(address.getName())) {

View File

@@ -149,6 +149,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
public abstract Optional<InsertResult> insertMessageInbox(IncomingMediaMessage retrieved, String contentLocation, long threadId) throws MmsException;
public abstract Pair<Long, Long> insertMessageInbox(@NonNull NotificationInd notification, int subscriptionId);
public abstract Optional<InsertResult> insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId) throws MmsException;
public abstract @NonNull InsertResult insertDecryptionFailedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp);
public abstract long insertMessageOutbox(long threadId, OutgoingTextMessage message, boolean forceSms, long date, InsertListener insertListener);
public abstract long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException;
public abstract long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, int defaultReceiptStatus, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException;

View File

@@ -1383,6 +1383,11 @@ public class MmsDatabase extends MessageDatabase {
return new Pair<>(messageId, threadId);
}
@Override
public @NonNull InsertResult insertDecryptionFailedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp) {
throw new UnsupportedOperationException();
}
@Override
public void markIncomingNotificationReceived(long threadId) {
notifyConversationListeners(threadId);

View File

@@ -1038,7 +1038,7 @@ public class SmsDatabase extends MessageDatabase {
if (groupRecipient == null) threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
else threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient);
ContentValues values = new ContentValues(6);
ContentValues values = new ContentValues();
values.put(RECIPIENT_ID, message.getSender().serialize());
values.put(ADDRESS_DEVICE_ID, message.getSenderDeviceId());
values.put(DATE_RECEIVED, System.currentTimeMillis());
@@ -1093,6 +1093,36 @@ public class SmsDatabase extends MessageDatabase {
return insertMessageInbox(message, Types.BASE_INBOX_TYPE);
}
@Override
public @NonNull InsertResult insertDecryptionFailedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(Recipient.resolved(recipientId));
long type = Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT;
type = type & (Types.TOTAL_MASK - Types.ENCRYPTION_MASK) | Types.ENCRYPTION_REMOTE_FAILED_BIT;
ContentValues values = new ContentValues();
values.put(RECIPIENT_ID, recipientId.serialize());
values.put(ADDRESS_DEVICE_ID, senderDeviceId);
values.put(DATE_RECEIVED, System.currentTimeMillis());
values.put(DATE_SENT, sentTimestamp);
values.put(DATE_SERVER, -1);
values.put(READ, 0);
values.put(TYPE, type);
values.put(THREAD_ID, threadId);
long messageId = db.insert(TABLE_NAME, null, values);
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
DatabaseFactory.getThreadDatabase(context).update(threadId, true);
notifyConversationListeners(threadId);
ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId));
return new InsertResult(messageId, threadId);
}
@Override
public long insertMessageOutbox(long threadId, OutgoingTextMessage message,
boolean forceSms, long date, InsertListener insertListener)

View File

@@ -191,6 +191,8 @@ public abstract class MessageRecord extends DisplayRecord {
else return fromRecipient(getIndividualRecipient(), r-> context.getString(R.string.SmsMessageRecord_secure_session_reset_s, r.getDisplayName(context)), R.drawable.ic_update_info_16);
} else if (isGroupV1MigrationEvent()) {
return getGroupMigrationEventDescription(context);
} else if (isFailedDecryptionType()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_chat_session_refreshed), R.drawable.ic_refresh_16);
}
return null;
@@ -436,7 +438,7 @@ public abstract class MessageRecord extends DisplayRecord {
public boolean isUpdate() {
return isGroupAction() || isJoined() || isExpirationTimerUpdate() || isCallLog() ||
isEndSession() || isIdentityUpdate() || isIdentityVerified() || isIdentityDefault() ||
isProfileChange() || isGroupV1MigrationEvent();
isProfileChange() || isGroupV1MigrationEvent() || isFailedDecryptionType();
}
public boolean isMediaPending() {
@@ -471,6 +473,10 @@ public abstract class MessageRecord extends DisplayRecord {
return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure());
}
public boolean isFailedDecryptionType() {
return MmsSmsColumns.Types.isFailedDecryptType(type);
}
protected static SpannableString emphasisAdded(String sequence) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

View File

@@ -66,7 +66,7 @@ public class SmsMessageRecord extends MessageRecord {
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
if (SmsDatabase.Types.isFailedDecryptType(type)) {
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message));
return emphasisAdded(context.getString(R.string.MessageRecord_chat_session_refreshed));
} else if (isCorruptedKeyExchange()) {
return emphasisAdded(context.getString(R.string.SmsMessageRecord_received_corrupted_key_exchange_message));
} else if (isInvalidVersionKeyExchange()) {

View File

@@ -0,0 +1,126 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.SessionUtil;
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.io.IOException;
/**
* - Archives the session associated with the specified device
* - Inserts an error message in the conversation
* - Sends a new, empty message to trigger a fresh session with the specified device
*
* This will only be run when all decryptions have finished, and there can only be one enqueued
* per websocket drain cycle.
*/
public class AutomaticSessionResetJob extends BaseJob {
private static final String TAG = Log.tag(AutomaticSessionResetJob.class);
public static final String KEY = "AutomaticSessionResetJob";
private static final String KEY_RECIPIENT_ID = "recipient_id";
private static final String KEY_DEVICE_ID = "device_id";
private static final String KEY_SENT_TIMESTAMP = "sent_timestamp";
private final RecipientId recipientId;
private final int deviceId;
private final long sentTimestamp;
public AutomaticSessionResetJob(@NonNull RecipientId recipientId, int deviceId, long sentTimestamp) {
this(new Parameters.Builder()
.setQueue(PushProcessMessageJob.getQueueName(recipientId))
.addConstraint(DecryptionsDrainedConstraint.KEY)
.setMaxInstancesForQueue(1)
.build(),
recipientId,
deviceId,
sentTimestamp);
}
private AutomaticSessionResetJob(@NonNull Parameters parameters,
@NonNull RecipientId recipientId,
int deviceId,
long sentTimestamp)
{
super(parameters);
this.recipientId = recipientId;
this.deviceId = deviceId;
this.sentTimestamp = sentTimestamp;
}
@Override
public @NonNull Data serialize() {
return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize())
.putInt(KEY_DEVICE_ID, deviceId)
.putLong(KEY_SENT_TIMESTAMP, sentTimestamp)
.build();
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
protected void onRun() throws Exception {
SessionUtil.archiveSession(context, recipientId, deviceId);
insertLocalMessage();
sendNullMessage();
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryLaterException;
}
@Override
public void onFailure() {
}
private void insertLocalMessage() {
MessageDatabase.InsertResult result = DatabaseFactory.getSmsDatabase(context).insertDecryptionFailedMessage(recipientId, deviceId, sentTimestamp);
ApplicationDependencies.getMessageNotifier().updateNotification(context, result.getThreadId());
}
private void sendNullMessage() throws IOException {
Recipient recipient = Recipient.resolved(recipientId);
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
SignalServiceAddress address = RecipientUtil.toSignalServiceAddress(context, recipient);
Optional<UnidentifiedAccessPair> unidentifiedAccess = UnidentifiedAccessUtil.getAccessFor(context, recipient);
try {
messageSender.sendNullMessage(address, unidentifiedAccess);
} catch (UntrustedIdentityException e) {
Log.w(TAG, "Unable to send null message.");
}
}
public static final class Factory implements Job.Factory<AutomaticSessionResetJob> {
@Override
public @NonNull AutomaticSessionResetJob create(@NonNull Parameters parameters, @NonNull Data data) {
return new AutomaticSessionResetJob(parameters,
RecipientId.from(data.getString(KEY_RECIPIENT_ID)),
data.getInt(KEY_DEVICE_ID),
data.getLong(KEY_SENT_TIMESTAMP));
}
}
}

View File

@@ -65,6 +65,7 @@ public final class JobManagerFactories {
put(AttachmentUploadJob.KEY, new AttachmentUploadJob.Factory());
put(AttachmentMarkUploadedJob.KEY, new AttachmentMarkUploadedJob.Factory());
put(AttachmentCompressionJob.KEY, new AttachmentCompressionJob.Factory());
put(AutomaticSessionResetJob.KEY, new AutomaticSessionResetJob.Factory());
put(AvatarGroupsV1DownloadJob.KEY, new AvatarGroupsV1DownloadJob.Factory());
put(AvatarGroupsV2DownloadJob.KEY, new AvatarGroupsV2DownloadJob.Factory());
put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory());

View File

@@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -179,22 +180,11 @@ public final class PushDecryptMessageJob extends BaseJob {
smsMessageId,
envelope.getTimestamp()));
} catch (ProtocolInvalidMessageException | ProtocolInvalidKeyIdException | ProtocolInvalidKeyException | ProtocolUntrustedIdentityException e) {
} catch (ProtocolInvalidMessageException | ProtocolInvalidKeyIdException | ProtocolInvalidKeyException | ProtocolUntrustedIdentityException | ProtocolNoSessionException e) {
Log.w(TAG, String.valueOf(envelope.getTimestamp()), e);
return Collections.singletonList(new PushProcessMessageJob(PushProcessMessageJob.MessageState.CORRUPT_MESSAGE,
toExceptionMetadata(e),
messageId,
smsMessageId,
envelope.getTimestamp()));
} catch (ProtocolNoSessionException e) {
Log.w(TAG, String.valueOf(envelope.getTimestamp()), e);
return Collections.singletonList(new PushProcessMessageJob(PushProcessMessageJob.MessageState.NO_SESSION,
toExceptionMetadata(e),
messageId,
smsMessageId,
envelope.getTimestamp()));
return Collections.singletonList(new AutomaticSessionResetJob(Recipient.external(context, e.getSender()).getId(),
e.getSenderDevice(),
envelope.getTimestamp()));
} catch (ProtocolLegacyMessageException e) {
Log.w(TAG, String.valueOf(envelope.getTimestamp()), e);
return Collections.singletonList(new PushProcessMessageJob(PushProcessMessageJob.MessageState.LEGACY_MESSAGE,

View File

@@ -483,16 +483,6 @@ public final class PushProcessMessageJob extends BaseJob {
handleInvalidVersionMessage(e.sender, e.senderDevice, timestamp, smsMessageId);
break;
case CORRUPT_MESSAGE:
warn(TAG, String.valueOf(timestamp), "Handling corrupt message.");
handleCorruptMessage(e.sender, e.senderDevice, timestamp, smsMessageId);
break;
case NO_SESSION:
warn(TAG, String.valueOf(timestamp), "Handling no session.");
handleNoSessionMessage(e.sender, e.senderDevice, timestamp, smsMessageId);
break;
case LEGACY_MESSAGE:
warn(TAG, String.valueOf(timestamp), "Handling legacy message.");
handleLegacyMessage(e.sender, e.senderDevice, timestamp, smsMessageId);
@@ -1474,23 +1464,6 @@ public final class PushProcessMessageJob extends BaseJob {
}
}
private void handleNoSessionMessage(@NonNull String sender, int senderDevice, long timestamp,
@NonNull Optional<Long> smsMessageId)
{
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
if (!smsMessageId.isPresent()) {
Optional<InsertResult> insertResult = insertPlaceholder(sender, senderDevice, timestamp);
if (insertResult.isPresent()) {
smsDatabase.markAsNoSession(insertResult.get().getMessageId());
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId());
}
} else {
smsDatabase.markAsNoSession(smsMessageId.get());
}
}
private void handleUnsupportedDataMessage(@NonNull String sender,
int senderDevice,
@NonNull Optional<GroupId> groupId,
@@ -2049,8 +2022,6 @@ public final class PushProcessMessageJob extends BaseJob {
public enum MessageState {
DECRYPTED_OK,
INVALID_VERSION,
CORRUPT_MESSAGE,
NO_SESSION,
LEGACY_MESSAGE,
DUPLICATE_MESSAGE,
UNSUPPORTED_DATA_MESSAGE