diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 8c781a4097..badc9a32ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -148,11 +148,12 @@ public final class GroupManager { public static void updateGroupFromServer(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, int version, - long timestamp) + long timestamp, + @Nullable byte[] signedGroupChange) throws GroupChangeBusyException, IOException, GroupNotAMemberException { try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { - updater.updateLocalToServerVersion(version, timestamp); + updater.updateLocalToServerVersion(version, timestamp, signedGroupChange); } } 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 9d70807d68..fc5e9394c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -6,6 +6,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import com.google.protobuf.InvalidProtocolBufferException; + import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.Member; @@ -144,7 +146,7 @@ final class GroupManagerV2 { groupDatabase.onAvatarUpdated(groupId, avatar != null); DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true); - return sendGroupUpdate(masterKey, decryptedGroup, null); + return sendGroupUpdate(masterKey, decryptedGroup, null, null); } catch (VerificationFailedException | InvalidGroupStateException e) { throw new GroupChangeFailedException(e); } @@ -354,7 +356,7 @@ final class GroupManagerV2 { throws IOException, GroupNotAMemberException, GroupChangeFailedException { GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey) - .updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis()); + .updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null); if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED || groupUpdateResult.getLatestServer() == null) { throw new GroupChangeFailedException(); @@ -390,24 +392,24 @@ final class GroupManagerV2 { throw new IOException(e); } - commitToServer(changeActions); + GroupChange signedGroupChange = commitToServer(changeActions); groupDatabase.update(groupId, decryptedGroupState); - return sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange); + return sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange, signedGroupChange); } - private void commitToServer(GroupChange.Actions change) + private GroupChange commitToServer(GroupChange.Actions change) throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException { try { - groupsV2Api.patchGroup(change, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); + return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); } catch (NotInGroupException e) { Log.w(TAG, e); throw new GroupNotAMemberException(e); } catch (AuthorizationFailedException e) { Log.w(TAG, e); throw new GroupInsufficientRightsException(e); - } catch (VerificationFailedException | InvalidGroupStateException e) { + } catch (VerificationFailedException e) { Log.w(TAG, e); throw new GroupChangeFailedException(e); } @@ -430,11 +432,25 @@ final class GroupManagerV2 { } @WorkerThread - void updateLocalToServerVersion(int version, long timestamp) + void updateLocalToServerVersion(int version, long timestamp, @Nullable byte[] signedGroupChange) throws IOException, GroupNotAMemberException { - new GroupsV2StateProcessor(context).forGroup(groupMasterKey) - .updateLocalGroupToRevision(version, timestamp); + new GroupsV2StateProcessor(context).forGroup(groupMasterKey) + .updateLocalGroupToRevision(version, timestamp, getDecryptedGroupChange(signedGroupChange)); + } + + private DecryptedGroupChange getDecryptedGroupChange(@Nullable byte[] signedGroupChange) { + if (signedGroupChange != null) { + GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); + + try { + return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true); + } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) { + Log.w(TAG, "Unable to verify supplied group change", e); + } + } + + return null; } @Override @@ -445,11 +461,12 @@ final class GroupManagerV2 { private @NonNull GroupManager.GroupActionResult sendGroupUpdate(@NonNull GroupMasterKey masterKey, @NonNull DecryptedGroup decryptedGroup, - @Nullable DecryptedGroupChange plainGroupChange) + @Nullable DecryptedGroupChange plainGroupChange, + @Nullable GroupChange signedGroupChange) { GroupId.V2 groupId = GroupId.v2(masterKey); Recipient groupRecipient = Recipient.externalGroup(context, groupId); - DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange); + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange, signedGroupChange); OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java index 88b0dcd779..43b5706f03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -8,6 +8,7 @@ import androidx.annotation.WorkerThread; import com.google.protobuf.ByteString; +import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; @@ -48,16 +49,20 @@ public final class GroupProtoUtil { public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey, @NonNull DecryptedGroup decryptedGroup, - @Nullable DecryptedGroupChange plainGroupChange) + @Nullable DecryptedGroupChange plainGroupChange, + @Nullable GroupChange signedServerChange) { int version = plainGroupChange != null ? plainGroupChange.getVersion() : decryptedGroup.getVersion(); - SignalServiceProtos.GroupContextV2 groupContext = SignalServiceProtos.GroupContextV2.newBuilder() - .setMasterKey(ByteString.copyFrom(masterKey.serialize())) - .setRevision(version) - .build(); + SignalServiceProtos.GroupContextV2.Builder contextBuilder = SignalServiceProtos.GroupContextV2.newBuilder() + .setMasterKey(ByteString.copyFrom(masterKey.serialize())) + .setRevision(version); + + if (signedServerChange != null) { + contextBuilder.setGroupChange(signedServerChange.toByteString()); + } DecryptedGroupV2Context.Builder builder = DecryptedGroupV2Context.newBuilder() - .setContext(groupContext) + .setContext(contextBuilder.build()) .setGroupState(decryptedGroup); if (plainGroupChange != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index e3523dcbed..eafbd9a9b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -132,21 +132,47 @@ public final class GroupsV2StateProcessor { */ @WorkerThread public GroupUpdateResult updateLocalGroupToRevision(final int revision, - final long timestamp) + final long timestamp, + @Nullable DecryptedGroupChange signedGroupChange) throws IOException, GroupNotAMemberException { if (localIsAtLeast(revision)) { return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null); } - GlobalGroupState inputGroupState; - try { - inputGroupState = queryServer(); - } catch (GroupNotAMemberException e) { - Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message"); - insertGroupLeave(); - throw e; + GlobalGroupState inputGroupState = null; + + DecryptedGroup localState = groupDatabase.getGroup(groupId) + .transform(g -> g.requireV2GroupProperties().getDecryptedGroup()) + .orNull(); + + if (signedGroupChange != null && + localState != null && + localState.getVersion() + 1 == signedGroupChange.getVersion() && + revision == signedGroupChange.getVersion()) + { + try { + Log.i(TAG, "Applying P2P group change"); + DecryptedGroup newState = DecryptedGroupUtil.apply(localState, signedGroupChange); + + inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new GroupLogEntry(newState, signedGroupChange))); + } catch (DecryptedGroupUtil.NotAbleToApplyChangeException e) { + Log.w(TAG, "Unable to apply P2P group change", e); + } } + + if (inputGroupState == null) { + try { + inputGroupState = queryServer(localState); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message"); + insertGroupLeave(); + throw e; + } + } else { + Log.i(TAG, "Saved server query for group change"); + } + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision); DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState(); @@ -185,7 +211,7 @@ public final class GroupsV2StateProcessor { .addDeleteMembers(UuidUtil.toByteString(selfUuid)) .build(); - DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, simulatedGroupState, simulatedGroupChange); + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, simulatedGroupState, simulatedGroupChange, null); OutgoingGroupUpdateMessage leaveMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, @@ -245,7 +271,7 @@ public final class GroupsV2StateProcessor { private void insertUpdateMessages(long timestamp, Collection processedLogEntries) { for (GroupLogEntry entry : processedLogEntries) { - storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, entry.getGroup(), entry.getChange()), timestamp); + storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, entry.getGroup(), entry.getChange(), null), timestamp); } } @@ -266,15 +292,12 @@ public final class GroupsV2StateProcessor { } } - private GlobalGroupState queryServer() + private @NonNull GlobalGroupState queryServer(@Nullable DecryptedGroup localState) throws IOException, GroupNotAMemberException { DecryptedGroup latestServerGroup; List history; - UUID selfUuid = Recipient.self().getUuid().get(); - DecryptedGroup localState = groupDatabase.getGroup(groupId) - .transform(g -> g.requireV2GroupProperties().getDecryptedGroup()) - .orNull(); + UUID selfUuid = Recipient.self().getUuid().get(); try { latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 3b33246f7e..56dadfa938 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -445,7 +445,7 @@ public final class PushProcessMessageJob extends BaseJob { throws IOException, GroupChangeBusyException { try { - GroupManager.updateGroupFromServer(context, groupMasterKey, groupV2.getRevision(), content.getTimestamp()); + GroupManager.updateGroupFromServer(context, groupMasterKey, groupV2.getRevision(), content.getTimestamp(), groupV2.getSignedGroupChange()); return true; } catch (GroupNotAMemberException e) { Log.w(TAG, "Ignoring message for a group we're not in"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java index ad1f0c14e9..4cac95af0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java @@ -87,7 +87,7 @@ public final class RequestGroupV2InfoJob extends BaseJob { return; } - GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis()); + GroupManager.updateGroupFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), toRevision, System.currentTimeMillis(), null); } @Override diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 70ca506322..995d78ac43 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -1072,10 +1072,17 @@ public class SignalServiceMessageSender { } private static GroupContextV2 createGroupContent(SignalServiceGroupV2 group) { - return GroupContextV2.newBuilder() - .setMasterKey(ByteString.copyFrom(group.getMasterKey().serialize())) - .setRevision(group.getRevision()) - .build(); + GroupContextV2.Builder builder = GroupContextV2.newBuilder() + .setMasterKey(ByteString.copyFrom(group.getMasterKey().serialize())) + .setRevision(group.getRevision()); + + + byte[] signedGroupChange = group.getSignedGroupChange(); + if (signedGroupChange != null && signedGroupChange.length <= 2048) { + builder.setGroupChange(ByteString.copyFrom(signedGroupChange)); + } + + return builder.build(); } private List createSharedContactContent(List contacts) throws IOException { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java index 7cf3f8b0ed..954587efd9 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java @@ -132,15 +132,11 @@ public final class GroupsV2Api { return form.getKey(); } - public DecryptedGroupChange patchGroup(GroupChange.Actions groupChange, - GroupSecretParams groupSecretParams, - GroupsV2AuthorizationString authorization) - throws IOException, VerificationFailedException, InvalidGroupStateException + public GroupChange patchGroup(GroupChange.Actions groupChange, + GroupsV2AuthorizationString authorization) + throws IOException { - GroupChange groupChanges = socket.patchGroupsV2Group(groupChange, authorization.toString()); - - return groupsOperations.forGroup(groupSecretParams) - .decryptChange(groupChanges, true); + return socket.patchGroupsV2Group(groupChange, authorization.toString()); } private static HashMap parseCredentialResponse(CredentialResponse credentialResponse)