From 4325f714b9ecbb333b55dc1021246174c83b2c98 Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Fri, 10 Jul 2020 12:32:40 -0300 Subject: [PATCH] Silent group update send job for profile key rotation. --- .../securesms/groups/GroupManagerV2.java | 11 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../jobs/PushGroupSilentUpdateSendJob.java | 206 ++++++++++++++++++ .../api/messages/SignalServiceGroupV2.java | 23 ++ 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 6c6c3750f..4d7a0361f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2 import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper; import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; @@ -482,9 +483,13 @@ final class GroupManagerV2 { Collections.emptyList(), Collections.emptyList()); - long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); - - return new GroupManager.GroupActionResult(groupRecipient, threadId); + if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { + ApplicationDependencies.getJobManager().add(PushGroupSilentUpdateSendJob.create(context, groupId, decryptedGroup, outgoingMessage)); + return new GroupManager.GroupActionResult(groupRecipient, -1); + } else { + long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); + return new GroupManager.GroupActionResult(groupRecipient, threadId); + } } private static @NonNull AccessControl.AccessRequired rightsToAccessControl(@NonNull GroupAccessControl rights) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index a5674d14e..bef611766 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -83,6 +83,7 @@ public final class JobManagerFactories { put(PushDecryptMessageJob.KEY, new PushDecryptMessageJob.Factory()); put(PushProcessMessageJob.KEY, new PushProcessMessageJob.Factory()); put(PushGroupSendJob.KEY, new PushGroupSendJob.Factory()); + put(PushGroupSilentUpdateSendJob.KEY, new PushGroupSilentUpdateSendJob.Factory()); put(PushGroupUpdateJob.KEY, new PushGroupUpdateJob.Factory()); put(PushMediaSendJob.KEY, new PushMediaSendJob.Factory()); put(PushNotificationReceiveJob.KEY, new PushNotificationReceiveJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java new file mode 100644 index 000000000..cd837a023 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java @@ -0,0 +1,206 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; +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.thoughtcrime.securesms.util.Base64; +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.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Sends an update to a group without inserting a change message locally. + *

+ * An example usage would be to update a group with a profile key change. + */ +public final class PushGroupSilentUpdateSendJob extends BaseJob { + + public static final String KEY = "PushGroupSilentSendJob"; + + private static final String TAG = Log.tag(PushGroupSilentUpdateSendJob.class); + + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + private static final String KEY_TIMESTAMP = "timestamp"; + private static final String KEY_GROUP_CONTEXT_V2 = "group_context_v2"; + + private final List recipients; + private final int initialRecipientCount; + private final SignalServiceProtos.GroupContextV2 groupContextV2; + private final long timestamp; + + @WorkerThread + public static @NonNull Job create(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull DecryptedGroup decryptedGroup, + @NonNull OutgoingGroupUpdateMessage groupMessage) + { + List memberUuids = DecryptedGroupUtil.toUuidList(decryptedGroup.getMembersList()); + List pendingUuids = DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList()); + + Set recipients = Stream.concat(Stream.of(memberUuids), Stream.of(pendingUuids)) + .filter(uuid -> !UuidUtil.UNKNOWN_UUID.equals(uuid)) + .filter(uuid -> !Recipient.self().getUuid().get().equals(uuid)) + .map(uuid -> RecipientId.from(uuid, null)) + .collect(Collectors.toSet()); + + MessageGroupContext.GroupV2Properties properties = groupMessage.requireGroupV2Properties(); + SignalServiceProtos.GroupContextV2 groupContext = properties.getGroupContext(); + + String queue = Recipient.externalGroup(context, groupId).getId().toQueueKey(); + + return new PushGroupSilentUpdateSendJob(new ArrayList<>(recipients), + recipients.size(), + groupMessage.getSentTimeMillis(), + groupContext, + new Parameters.Builder() + .setQueue(queue) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private PushGroupSilentUpdateSendJob(@NonNull List recipients, + int initialRecipientCount, + long timestamp, + @NonNull SignalServiceProtos.GroupContextV2 groupContextV2, + @NonNull Parameters parameters) + { + super(parameters); + + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + this.groupContextV2 = groupContextV2; + this.timestamp = timestamp; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .putLong(KEY_TIMESTAMP, timestamp) + .putString(KEY_GROUP_CONTEXT_V2, Base64.encodeBytes(groupContextV2.toByteArray())) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(destinations); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + Log.w(TAG, "Failed to send remote delete to all recipients! (" + (initialRecipientCount - recipients.size() + "/" + initialRecipientCount + ")") ); + } + + private @NonNull List deliver(@NonNull List destinations) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = Stream.of(destinations).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); + List> unidentifiedAccess = Stream.of(destinations).map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)).toList(); + + SignalServiceGroupV2 group = SignalServiceGroupV2.fromProtobuf(groupContextV2); + SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(timestamp) + .asGroupMessage(group) + .build(); + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, groupDataMessage); + + Stream.of(results) + .filter(r -> r.getIdentityFailure() != null) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Identity failure for " + r.getId())); + + Stream.of(results) + .filter(SendMessageResult::isUnregisteredFailure) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Unregistered failure for " + r.getId())); + + return Stream.of(results) + .filter(r -> r.getSuccess() != null || r.getIdentityFailure() != null || r.isUnregisteredFailure()) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .toList(); + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + PushGroupSilentUpdateSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + long timestamp = data.getLong(KEY_TIMESTAMP); + byte[] contextBytes = Base64.decodeOrThrow(data.getString(KEY_GROUP_CONTEXT_V2)); + + SignalServiceProtos.GroupContextV2 groupContextV2; + try { + groupContextV2 = SignalServiceProtos.GroupContextV2.parseFrom(contextBytes); + } catch (InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + + return new PushGroupSilentUpdateSendJob(recipients, initialRecipientCount, timestamp, groupContextV2, parameters); + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroupV2.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroupV2.java index 6db20910d..1cefa2d3c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroupV2.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceGroupV2.java @@ -1,6 +1,8 @@ package org.whispersystems.signalservice.api.messages; +import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.groups.GroupMasterKey; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; /** * Group information to include in SignalServiceMessages destined to v2 groups. @@ -20,6 +22,27 @@ public final class SignalServiceGroupV2 { this.signedGroupChange = builder.signedGroupChange != null ? builder.signedGroupChange.clone() : null; } + /** + * Creates a context model populated from a protobuf group V2 context. + */ + public static SignalServiceGroupV2 fromProtobuf(SignalServiceProtos.GroupContextV2 groupContextV2) { + GroupMasterKey masterKey; + try { + masterKey = new GroupMasterKey(groupContextV2.getMasterKey().toByteArray()); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + + Builder builder = newBuilder(masterKey); + + if (groupContextV2.hasGroupChange() && !groupContextV2.getGroupChange().isEmpty()) { + builder.withSignedGroupChange(groupContextV2.getGroupChange().toByteArray()); + } + + return builder.withRevision(groupContextV2.getRevision()) + .build(); + } + public GroupMasterKey getMasterKey() { return masterKey; }