diff --git a/app/build.gradle b/app/build.gradle index 32ccdb9c88..a151fa84f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -295,7 +295,7 @@ dependencies { implementation 'org.signal:aesgcmprovider:0.0.3' implementation project(':libsignal-service') - implementation 'org.signal:zkgroup-android:0.4.1' + implementation 'org.signal:zkgroup-android:0.7.0' implementation 'org.signal:argon2:13.1@aar' diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 4e1f8849cf..d0c99459a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -148,9 +148,11 @@ import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; import org.thoughtcrime.securesms.giph.ui.GiphyActivity; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity; @@ -947,7 +949,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } catch (GroupInsufficientRightsException e) { Log.w(TAG, e); return ConversationActivity.this.getString(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this); - } catch (GroupChangeFailedException e) { + } catch (GroupNotAMemberException e) { + Log.w(TAG, e); + return ConversationActivity.this.getString(R.string.ManageGroupActivity_youre_not_a_member_of_the_group); + } catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) { Log.w(TAG, e); return ConversationActivity.this.getString(R.string.ManageGroupActivity_failed_to_update_the_group); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 64283c929a..7b710b9480 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -267,13 +267,14 @@ public final class GroupDatabase extends Database { create(groupId, null, members, null, null, null, null); } - public void create(@NonNull GroupId.V2 groupId, - @Nullable SignalServiceAttachmentPointer avatar, - @Nullable String relay, - @NonNull GroupMasterKey groupMasterKey, - @NonNull DecryptedGroup groupState) + public GroupId.V2 create(@NonNull GroupMasterKey groupMasterKey, + @NonNull DecryptedGroup groupState) { - create(groupId, groupState.getTitle(), Collections.emptyList(), avatar, relay, groupMasterKey, groupState); + GroupId.V2 groupId = GroupId.v2(groupMasterKey); + + create(groupId, groupState.getTitle(), Collections.emptyList(), null, null, groupMasterKey, groupState); + + return groupId; } /** @@ -287,12 +288,14 @@ public final class GroupDatabase extends Database { @Nullable GroupMasterKey groupMasterKey, @Nullable DecryptedGroup groupState) { - List members = new ArrayList<>(new HashSet<>(memberCollection)); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId); + List members = new ArrayList<>(new HashSet<>(memberCollection)); Collections.sort(members); ContentValues contentValues = new ContentValues(); - contentValues.put(RECIPIENT_ID, DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId).serialize()); + contentValues.put(RECIPIENT_ID, groupRecipientId.serialize()); contentValues.put(GROUP_ID, groupId.toString()); contentValues.put(TITLE, title); contentValues.put(MEMBERS, RecipientId.toSerializedList(members)); @@ -328,8 +331,11 @@ public final class GroupDatabase extends Database { databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, contentValues); - RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); + if (groupState != null && groupState.hasDisappearingMessagesTimer()) { + recipientDatabase.setExpireMessages(groupRecipientId, groupState.getDisappearingMessagesTimer().getDuration()); + } + + Recipient.live(groupRecipientId).refresh(); notifyConversationListListeners(); } @@ -365,8 +371,10 @@ public final class GroupDatabase extends Database { } public void update(@NonNull GroupId.V2 groupId, @NonNull DecryptedGroup decryptedGroup) { - String title = decryptedGroup.getTitle(); - ContentValues contentValues = new ContentValues(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId); + String title = decryptedGroup.getTitle(); + ContentValues contentValues = new ContentValues(); contentValues.put(TITLE, title); contentValues.put(V2_REVISION, decryptedGroup.getVersion()); @@ -377,8 +385,11 @@ public final class GroupDatabase extends Database { GROUP_ID + " = ?", new String[]{ groupId.toString() }); - RecipientId groupRecipient = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); - Recipient.live(groupRecipient).refresh(); + if (decryptedGroup.hasDisappearingMessagesTimer()) { + recipientDatabase.setExpireMessages(groupRecipientId, decryptedGroup.getDisappearingMessagesTimer().getDuration()); + } + + Recipient.live(groupRecipientId).refresh(); notifyConversationListListeners(); } @@ -499,6 +510,21 @@ public final class GroupDatabase extends Database { return RecipientId.toSerializedList(groupMembers); } + public List getAllGroupV2Ids() { + List result = new LinkedList<>(); + + try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, new String[]{ GROUP_ID }, null, null, null, null, null)) { + while (cursor.moveToNext()) { + GroupId groupId = GroupId.parseOrThrow(cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID))); + if (groupId.isV2()) { + result.add(groupId.requireV2()); + } + } + } + + return result; + } + public static class Reader implements Closeable { private final Cursor cursor; @@ -750,8 +776,8 @@ public final class GroupDatabase extends Database { FULL_MEMBERS_AND_PENDING_INCLUDING_SELF(true, true), FULL_MEMBERS_AND_PENDING_EXCLUDING_SELF(false, true); - private boolean includeSelf; - private boolean includePending; + private final boolean includeSelf; + private final boolean includePending; MemberSet(boolean includeSelf, boolean includePending) { this.includeSelf = includeSelf; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 5d81687c03..4a00579431 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -17,10 +17,12 @@ import net.sqlcipher.database.SQLiteDatabase; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; @@ -47,11 +49,14 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -977,6 +982,30 @@ public class RecipientDatabase extends Database { Recipient.live(id).refresh(); StorageSyncHelper.scheduleSyncForDataChange(); return true; + } + return false; + } + + /** + * Sets the profile key iff currently null. + *

+ * If it sets it, it also clears out the profile key credential and resets the unidentified access mode. + * @return true iff changed. + */ + public boolean setProfileKeyIfAbsent(@NonNull RecipientId id, @NonNull ProfileKey profileKey) { + SQLiteDatabase database = databaseHelper.getWritableDatabase(); + String selection = ID + " = ? AND " + PROFILE_KEY + " is NULL"; + String[] args = new String[]{id.serialize()}; + ContentValues valuesToSet = new ContentValues(3); + + valuesToSet.put(PROFILE_KEY, Base64.encodeBytes(profileKey.serialize())); + valuesToSet.putNull(PROFILE_KEY_CREDENTIAL); + valuesToSet.put(UNIDENTIFIED_ACCESS_MODE, UnidentifiedAccessMode.UNKNOWN.getMode()); + + if (database.update(TABLE_NAME, valuesToSet, selection, args) > 0) { + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + return true; } else { return false; } @@ -1013,6 +1042,56 @@ public class RecipientDatabase extends Database { } } + /** + * Fills in gaps (nulls) in profile key knowledge from new profile keys. + *

+ * If from authoritative source, this will overwrite local, otherwise it will only write to the + * database if missing. + */ + public Collection persistProfileKeySet(@NonNull ProfileKeySet profileKeySet) { + Map profileKeys = profileKeySet.getProfileKeys(); + Map authoritativeProfileKeys = profileKeySet.getAuthoritativeProfileKeys(); + int totalKeys = profileKeys.size() + authoritativeProfileKeys.size(); + + if (totalKeys == 0) { + return Collections.emptyList(); + } + + Log.i(TAG, String.format(Locale.US, "Persisting %d Profile keys, %d of which are authoritative", totalKeys, authoritativeProfileKeys.size())); + + HashSet updated = new HashSet<>(totalKeys); + RecipientId selfId = Recipient.self().getId(); + + for (Map.Entry entry : profileKeys.entrySet()) { + RecipientId recipientId = getOrInsertFromUuid(entry.getKey()); + + if (setProfileKeyIfAbsent(recipientId, entry.getValue())) { + Log.i(TAG, "Learned new profile key"); + updated.add(recipientId); + } + } + + for (Map.Entry entry : authoritativeProfileKeys.entrySet()) { + RecipientId recipientId = getOrInsertFromUuid(entry.getKey()); + + if (selfId.equals(recipientId)) { + Log.i(TAG, "Seen authoritative update for self"); + if (!entry.getValue().equals(ProfileKeyUtil.getSelfProfileKey())) { + Log.w(TAG, "Seen authoritative update for self that didn't match local, scheduling storage sync"); + StorageSyncHelper.scheduleSyncForDataChange(); + } + } else { + Log.i(TAG, String.format("Profile key from owner %s", recipientId)); + if (setProfileKey(recipientId, entry.getValue())) { + Log.i(TAG, "Learned new profile key from owner"); + updated.add(recipientId); + } + } + } + + return updated; + } + public void setProfileName(@NonNull RecipientId id, @NonNull ProfileName profileName) { ContentValues contentValues = new ContentValues(1); contentValues.put(PROFILE_GIVEN_NAME, profileName.getGivenName()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index 2ba161f0fe..45ad4fb648 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -210,23 +210,24 @@ final class GroupsV2UpdateMessageProducer { private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(youUuid); - for (ByteString member : change.getPromotePendingMembersList()) { - boolean newMemberIsYou = member.equals(youUuid); + for (DecryptedMember newMember : change.getPromotePendingMembersList()) { + ByteString uuid = newMember.getUuid(); + boolean newMemberIsYou = uuid.equals(youUuid); if (editorIsYou) { if (newMemberIsYou) { updates.add(context.getString(R.string.MessageRecord_you_accepted_invite)); } else { - updates.add(context.getString(R.string.MessageRecord_you_added_invited_member_s, describe(member))); + updates.add(context.getString(R.string.MessageRecord_you_added_invited_member_s, describe(uuid))); } } else { if (newMemberIsYou) { updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor()))); } else { - if (member.equals(change.getEditor())) { - updates.add(context.getString(R.string.MessageRecord_s_accepted_invite, describe(member))); + if (uuid.equals(change.getEditor())) { + updates.add(context.getString(R.string.MessageRecord_s_accepted_invite, describe(uuid))); } else { - updates.add(context.getString(R.string.MessageRecord_s_added_invited_member_s, describe(change.getEditor()), describe(member))); + updates.add(context.getString(R.string.MessageRecord_s_added_invited_member_s, describe(change.getEditor()), describe(uuid))); } } } @@ -282,7 +283,7 @@ final class GroupsV2UpdateMessageProducer { } } } - + private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(youUuid); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index aa820ecf18..aeb8d84c45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -7,11 +7,13 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.IncomingMessageProcessor; import org.thoughtcrime.securesms.gcm.MessageRetriever; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.keyvalue.KeyValueStore; import org.thoughtcrime.securesms.megaphone.MegaphoneRepository; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.IncomingMessageObserver; import org.thoughtcrime.securesms.util.EarlyMessageCache; import org.thoughtcrime.securesms.util.FeatureFlags; @@ -22,6 +24,8 @@ import org.whispersystems.signalservice.api.KeyBackupService; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; /** @@ -37,18 +41,20 @@ public class ApplicationDependencies { private static Application application; private static Provider provider; - private static SignalServiceAccountManager accountManager; - private static SignalServiceMessageSender messageSender; - private static SignalServiceMessageReceiver messageReceiver; - private static IncomingMessageProcessor incomingMessageProcessor; - private static MessageRetriever messageRetriever; - private static LiveRecipientCache recipientCache; - private static JobManager jobManager; - private static FrameRateTracker frameRateTracker; - private static KeyValueStore keyValueStore; - private static MegaphoneRepository megaphoneRepository; - private static GroupsV2Operations groupsV2Operations; - private static EarlyMessageCache earlyMessageCache; + private static SignalServiceAccountManager accountManager; + private static SignalServiceMessageSender messageSender; + private static SignalServiceMessageReceiver messageReceiver; + private static IncomingMessageProcessor incomingMessageProcessor; + private static MessageRetriever messageRetriever; + private static LiveRecipientCache recipientCache; + private static JobManager jobManager; + private static FrameRateTracker frameRateTracker; + private static KeyValueStore keyValueStore; + private static MegaphoneRepository megaphoneRepository; + private static GroupsV2Authorization groupsV2Authorization; + private static GroupsV2StateProcessor groupsV2StateProcessor; + private static GroupsV2Operations groupsV2Operations; + private static EarlyMessageCache earlyMessageCache; public static synchronized void init(@NonNull Application application, @NonNull Provider provider) { if (ApplicationDependencies.application != null || ApplicationDependencies.provider != null) { @@ -74,6 +80,16 @@ public class ApplicationDependencies { return accountManager; } + public static synchronized @NonNull GroupsV2Authorization getGroupsV2Authorization() { + assertInitialization(); + + if (groupsV2Authorization == null) { + groupsV2Authorization = getSignalServiceAccountManager().createGroupsV2Authorization(Recipient.self().getUuid().get()); + } + + return groupsV2Authorization; + } + public static synchronized @NonNull GroupsV2Operations getGroupsV2Operations() { assertInitialization(); @@ -91,6 +107,16 @@ public class ApplicationDependencies { 10); } + public static synchronized @NonNull GroupsV2StateProcessor getGroupsV2StateProcessor() { + assertInitialization(); + + if (groupsV2StateProcessor == null) { + groupsV2StateProcessor = new GroupsV2StateProcessor(application); + } + + return groupsV2StateProcessor; + } + public static synchronized @NonNull SignalServiceMessageSender getSignalServiceMessageSender() { assertInitialization(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java index 163a46999e..1f419a16e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BadGroupIdException.java @@ -1,15 +1,18 @@ package org.thoughtcrime.securesms.groups; +import androidx.annotation.NonNull; + public final class BadGroupIdException extends Exception { - BadGroupIdException(String message) { - super(message); - } BadGroupIdException() { super(); } - BadGroupIdException(Exception e) { + BadGroupIdException(@NonNull String message) { + super(message); + } + + BadGroupIdException(@NonNull Exception e) { super(e); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeBusyException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeBusyException.java new file mode 100644 index 0000000000..100eb4a93a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeBusyException.java @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.NonNull; + +public final class GroupChangeBusyException extends Exception { + + public GroupChangeBusyException(@NonNull Throwable throwable) { + super(throwable); + } + + public GroupChangeBusyException(@NonNull String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java index 9ad71e1b46..c054cac5f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java @@ -1,8 +1,17 @@ package org.thoughtcrime.securesms.groups; +import androidx.annotation.NonNull; + public final class GroupChangeFailedException extends Exception { - GroupChangeFailedException(Throwable throwable) { + GroupChangeFailedException() { + } + + GroupChangeFailedException(@NonNull Throwable throwable) { super(throwable); } + + GroupChangeFailedException(@NonNull String message) { + super(message); + } } 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 5c90230e1a..b0f7d678d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.BitmapUtil; import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; -import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.util.InvalidNumberException; import java.io.IOException; @@ -34,7 +33,7 @@ public final class GroupManager { { Set addresses = getMemberIds(members); - return V1GroupManager.createGroup(context, addresses, avatar, name, mms); + return GroupManagerV1.createGroup(context, addresses, avatar, name, mms); } @WorkerThread @@ -48,7 +47,7 @@ public final class GroupManager { List members = DatabaseFactory.getGroupDatabase(context) .getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); - return V1GroupManager.updateGroup(context, groupId, getMemberIds(members), avatar, name); + return GroupManagerV1.updateGroup(context, groupId, getMemberIds(members), avatar, name); } public static GroupActionResult updateGroup(@NonNull Context context, @@ -60,7 +59,7 @@ public final class GroupManager { { Set addresses = getMemberIds(members); - return V1GroupManager.updateGroup(context, groupId, addresses, BitmapUtil.toByteArray(avatar), name); + return GroupManagerV1.updateGroup(context, groupId, addresses, BitmapUtil.toByteArray(avatar), name); } private static Set getMemberIds(Collection recipients) { @@ -74,17 +73,29 @@ public final class GroupManager { @WorkerThread public static boolean leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId) { - return V1GroupManager.leaveGroup(context, groupId.requireV1()); + return GroupManagerV1.leaveGroup(context, groupId.requireV1()); + } + + @WorkerThread + public static void updateGroupFromServer(@NonNull Context context, + @NonNull GroupId.V2 groupId, + int version) + throws GroupChangeBusyException, IOException, GroupNotAMemberException + { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId)) { + edit.updateLocalToServerVersion(version); + } } @WorkerThread public static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.Push groupId, int expirationTime) - throws GroupChangeFailedException, GroupInsufficientRightsException + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { if (groupId.isV2()) { - throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow timer change + new GroupManagerV2(context).edit(groupId.requireV2()) + .updateGroupTimer(expirationTime); } else { - V1GroupManager.updateGroupTimer(context, groupId.requireV1(), expirationTime); + GroupManagerV1.updateGroupTimer(context, groupId.requireV1(), expirationTime); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java similarity index 99% rename from app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java index 2c3551400c..7b6eafdc11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java @@ -45,9 +45,9 @@ import java.util.LinkedList; import java.util.List; import java.util.Set; -final class V1GroupManager { +final class GroupManagerV1 { - private static final String TAG = Log.tag(V1GroupManager.class); + private static final String TAG = Log.tag(GroupManagerV1.class); static @NonNull GroupActionResult createGroup(@NonNull Context context, @NonNull Set memberIds, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java new file mode 100644 index 0000000000..a73ff660c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -0,0 +1,200 @@ +package org.thoughtcrime.securesms.groups; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +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.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; +import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; +import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; +import org.whispersystems.signalservice.api.push.exceptions.ConflictException; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Collections; +import java.util.UUID; + +final class GroupManagerV2 { + + private static final String TAG = Log.tag(GroupManagerV2.class); + + private final Context context; + private final GroupDatabase groupDatabase; + private final GroupsV2Api groupsV2Api; + private final GroupsV2Operations groupsV2Operations; + private final GroupsV2Authorization authorization; + private final GroupsV2StateProcessor groupsV2StateProcessor; + private final UUID selfUuid; + + GroupManagerV2(@NonNull Context context) { + this.context = context; + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); + this.groupsV2Operations = ApplicationDependencies.getGroupsV2Operations(); + this.authorization = ApplicationDependencies.getGroupsV2Authorization(); + this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor(); + this.selfUuid = Recipient.self().getUuid().get(); + } + + @WorkerThread + GroupEditor edit(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException { + return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock()); + } + + class GroupEditor implements Closeable { + + private final Closeable lock; + private final GroupId.V2 groupId; + private final GroupMasterKey groupMasterKey; + private final GroupSecretParams groupSecretParams; + private final GroupsV2Operations.GroupOperations groupOperations; + + GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) { + GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + + this.lock = lock; + this.groupId = groupId; + this.groupMasterKey = v2GroupProperties.getGroupMasterKey(); + this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + this.groupOperations = groupsV2Operations.forGroup(groupSecretParams); + } + + @WorkerThread + @NonNull GroupManager.GroupActionResult updateGroupTimer(int expirationTime) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException + { + return commitChangeWithConflictResolution(groupOperations.createModifyGroupTimerChange(expirationTime)); + } + + void updateLocalToServerVersion(int version) + throws IOException, GroupNotAMemberException + { + new GroupsV2StateProcessor(context).forGroup(groupMasterKey) + .updateLocalGroupToRevision(version, System.currentTimeMillis()); + } + + private GroupManager.GroupActionResult commitChangeWithConflictResolution(GroupChange.Actions.Builder change) + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get())); + + for (int attempt = 0; attempt < 5; attempt++) { + try { + return commitChange(change); + } catch (ConflictException e) { + Log.w(TAG, "Conflict on group"); + GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey) + .updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis()); + + if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED || groupUpdateResult.getLatestServer() == null) { + throw new GroupChangeFailedException(); + } + + Log.w(TAG, "Group has been updated"); + try { + change = GroupChangeUtil.resolveConflict(groupUpdateResult.getLatestServer(), + groupOperations.decryptChange(change.build(), selfUuid), + change.build()); + } catch (VerificationFailedException | InvalidGroupStateException ex) { + throw new GroupChangeFailedException(ex); + } + } + } + + throw new GroupChangeFailedException("Unable to apply change to group after conflicts"); + } + + private GroupManager.GroupActionResult commitChange(GroupChange.Actions.Builder change) + throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException + { + final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId); + final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties(); + final int nextRevision = v2GroupProperties.getGroupRevision() + 1; + final GroupChange.Actions changeActions = change.setVersion(nextRevision).build(); + final DecryptedGroupChange decryptedChange; + final DecryptedGroup decryptedGroupState; + + try { + decryptedChange = groupOperations.decryptChange(changeActions, selfUuid); + decryptedGroupState = DecryptedGroupUtil.apply(v2GroupProperties.getDecryptedGroup(), decryptedChange); + } catch (VerificationFailedException | InvalidGroupStateException | DecryptedGroupUtil.NotAbleToApplyChangeException e) { + Log.w(TAG, e); + throw new IOException(e); + } + + commitToServer(changeActions); + groupDatabase.update(groupId, decryptedGroupState); + + return sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange); + } + + private void commitToServer(GroupChange.Actions change) + throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException + { + try { + groupsV2Api.patchGroup(change, groupSecretParams, authorization); + } 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) { + Log.w(TAG, e); + throw new GroupChangeFailedException(e); + } + } + + private @NonNull GroupManager.GroupActionResult sendGroupUpdate(@NonNull GroupMasterKey masterKey, + @NonNull DecryptedGroup decryptedGroup, + @Nullable DecryptedGroupChange plainGroupChange) + { + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); + Recipient groupRecipient = Recipient.resolved(groupRecipientId); + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange); + OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient, + decryptedGroupV2Context, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList()); + + long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); + + return new GroupManager.GroupActionResult(groupRecipient, threadId); + } + + @Override + public void close() throws IOException { + lock.close(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java new file mode 100644 index 0000000000..70604af8a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupNotAMemberException extends Exception { + + public GroupNotAMemberException(Throwable throwable) { + super(throwable); + } + + GroupNotAMemberException() { + } +} 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 e5af13b666..88b0dcd779 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -3,15 +3,24 @@ package org.thoughtcrime.securesms.groups; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import com.google.protobuf.ByteString; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.util.UUIDUtil; import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import java.util.List; import java.util.UUID; public final class GroupProtoUtil { @@ -19,6 +28,45 @@ public final class GroupProtoUtil { private GroupProtoUtil() { } + public static int findVersionWeWereAdded(@NonNull DecryptedGroup group, @NonNull UUID uuid) + throws GroupNotAMemberException + { + ByteString bytes = UuidUtil.toByteString(uuid); + for (DecryptedMember decryptedMember : group.getMembersList()) { + if (decryptedMember.getUuid().equals(bytes)) { + return decryptedMember.getJoinedAtVersion(); + } + } + for (DecryptedPendingMember decryptedMember : group.getPendingMembersList()) { + if (decryptedMember.getUuid().equals(bytes)) { + // Assume latest, we don't have any information about when pending members were invited + return group.getVersion(); + } + } + throw new GroupNotAMemberException(); + } + + public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey, + @NonNull DecryptedGroup decryptedGroup, + @Nullable DecryptedGroupChange plainGroupChange) + { + int version = plainGroupChange != null ? plainGroupChange.getVersion() : decryptedGroup.getVersion(); + SignalServiceProtos.GroupContextV2 groupContext = SignalServiceProtos.GroupContextV2.newBuilder() + .setMasterKey(ByteString.copyFrom(masterKey.serialize())) + .setRevision(version) + .build(); + + DecryptedGroupV2Context.Builder builder = DecryptedGroupV2Context.newBuilder() + .setContext(groupContext) + .setGroupState(decryptedGroup); + + if (plainGroupChange != null) { + builder.setChange(plainGroupChange); + } + + return builder.build(); + } + @WorkerThread public static Recipient pendingMemberToRecipient(@NonNull Context context, @NonNull DecryptedPendingMember pendingMember) { return uuidByteStringToRecipient(context, pendingMember.getUuid()); @@ -34,4 +82,16 @@ public final class GroupProtoUtil { return Recipient.externalPush(context, uuid, null); } + + public static boolean isMember(@NonNull UUID uuid, @NonNull List membersList) { + ByteString uuidBytes = UuidUtil.toByteString(uuid); + + for (DecryptedMember member : membersList) { + if (uuidBytes.equals(member.getUuid())) { + return true; + } + } + + return false; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2ProcessingLock.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2ProcessingLock.java new file mode 100644 index 0000000000..cdee50a005 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2ProcessingLock.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.groups; + +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.Util; + +import java.io.Closeable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +final class GroupsV2ProcessingLock { + + private static final String TAG = Log.tag(GroupsV2ProcessingLock.class); + + private GroupsV2ProcessingLock() { + } + + private static final Lock lock = new ReentrantLock(); + + @WorkerThread + static Closeable acquireGroupProcessingLock() throws GroupChangeBusyException { + return acquireGroupProcessingLock(5000); + } + + @WorkerThread + static Closeable acquireGroupProcessingLock(long timeoutMs) throws GroupChangeBusyException { + Util.assertNotMainThread(); + + try { + if (!lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) { + throw new GroupChangeBusyException("Failed to get a lock on the group processing in the timeout period"); + } + return lock::unlock; + } catch (InterruptedException e) { + Log.w(TAG, e); + throw new GroupChangeBusyException(e); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index bf9b79ce67..861f98f000 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -11,16 +11,19 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.groups.GroupAccessControl; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import java.io.IOException; import java.util.concurrent.ExecutorService; final class ManageGroupRepository { @@ -61,7 +64,10 @@ final class ManageGroupRepository { } catch (GroupInsufficientRightsException e) { Log.w(TAG, e); error.onError(FailureReason.NO_RIGHTS); - } catch (GroupChangeFailedException e) { + } catch (GroupNotAMemberException e) { + Log.w(TAG, e); + error.onError(FailureReason.NOT_A_MEMBER); + } catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) { Log.w(TAG, e); error.onError(FailureReason.OTHER); } @@ -132,6 +138,7 @@ final class ManageGroupRepository { public enum FailureReason { NO_RIGHTS(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this), + NOT_A_MEMBER(R.string.ManageGroupActivity_youre_not_a_member_of_the_group), OTHER(R.string.ManageGroupActivity_failed_to_update_the_group); private final @StringRes int toastMessage; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java index 270b8c6c6b..92d811f851 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java @@ -24,17 +24,17 @@ public final class GroupRightsDialog { rights = currentRights; builder = new AlertDialog.Builder(context) - .setTitle(type.message) - .setSingleChoiceItems(type.choices, currentRights.ordinal(), (dialog, which) -> rights = GroupAccessControl.values()[which]) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> { - }) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - GroupAccessControl newGroupAccessControl = rights; + .setTitle(type.message) + .setSingleChoiceItems(type.choices, currentRights.ordinal(), (dialog, which) -> rights = GroupAccessControl.values()[which]) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + GroupAccessControl newGroupAccessControl = rights; - if (newGroupAccessControl != currentRights) { - onChange.changed(currentRights, newGroupAccessControl); - } - }); + if (newGroupAccessControl != currentRights) { + onChange.changed(currentRights, newGroupAccessControl); + } + }); } public void show() { @@ -46,6 +46,7 @@ public final class GroupRightsDialog { } public enum Type { + MEMBERSHIP(R.string.GroupManagement_choose_who_can_add_or_invite_new_members, R.array.GroupManagement_edit_group_membership_choices), diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java new file mode 100644 index 0000000000..7e36971aad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.groups.v2; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.profiles.ProfileKey; +import org.thoughtcrime.securesms.logging.Log; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Collects profile keys from group states. + *

+ * Separates out "authoritative" profile keys that came from a group update created by their owner. + *

+ * Authoritative profile keys can be used to overwrite local profile keys. + * Non-authoritative profile keys can be used to fill in missing knowledge. + */ +public final class ProfileKeySet { + + private static final String TAG = Log.tag(ProfileKeySet.class); + + private final Map profileKeys = new LinkedHashMap<>(); + private final Map authoritativeProfileKeys = new LinkedHashMap<>(); + + /** + * Add new profile keys from the group state. + */ + public void addKeysFromGroupState(@NonNull DecryptedGroup group, + @Nullable UUID changeSource) + { + for (DecryptedMember member : group.getMembersList()) { + UUID memberUuid = UuidUtil.fromByteString(member.getUuid()); + ProfileKey profileKey; + try { + profileKey = new ProfileKey(member.getProfileKey().toByteArray()); + } catch (InvalidInputException e) { + Log.w(TAG, "Bad profile key in group"); + continue; + } + + if (changeSource != null) { + Log.d(TAG, String.format("Change %s by %s", memberUuid, changeSource)); + + if (changeSource.equals(memberUuid)) { + authoritativeProfileKeys.put(memberUuid, profileKey); + profileKeys.remove(memberUuid); + } else { + if (!authoritativeProfileKeys.containsKey(memberUuid)) { + profileKeys.put(memberUuid, profileKey); + } + } + } + } + } + + public Map getProfileKeys() { + return profileKeys; + } + + public Map getAuthoritativeProfileKeys() { + return authoritativeProfileKeys; + } +} 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 new file mode 100644 index 0000000000..c9ecb16388 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -0,0 +1,269 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.MmsDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.GroupProtoUtil; +import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization; +import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; +import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * Advances a groups state to a specified revision. + */ +public final class GroupsV2StateProcessor { + + private static final String TAG = Log.tag(GroupsV2StateProcessor.class); + + public static final int LATEST = GroupStateMapper.LATEST; + + private final Context context; + private final JobManager jobManager; + private final RecipientDatabase recipientDatabase; + private final GroupDatabase groupDatabase; + private final GroupsV2Authorization groupsV2Authorization; + private final GroupsV2Api groupsV2Api; + + public GroupsV2StateProcessor(@NonNull Context context) { + this.context = context.getApplicationContext(); + this.jobManager = ApplicationDependencies.getJobManager(); + this.groupsV2Authorization = ApplicationDependencies.getGroupsV2Authorization(); + this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); + this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + } + + public StateProcessorForGroup forGroup(@NonNull GroupMasterKey groupMasterKey) { + return new StateProcessorForGroup(groupMasterKey); + } + + public enum GroupState { + /** + * The message revision was inconsistent with server revision, should ignore + */ + INCONSISTENT, + + /** + * The local group was successfully updated to be consistent with the message revision + */ + GROUP_UPDATED, + + /** + * The local group is already consistent with the message revision or is ahead of the message revision + */ + GROUP_CONSISTENT_OR_AHEAD + } + + public static class GroupUpdateResult { + private final GroupState groupState; + @Nullable private DecryptedGroup latestServer; + + GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) { + this.groupState = groupState; + this.latestServer = latestServer; + } + + public GroupState getGroupState() { + return groupState; + } + + public @Nullable DecryptedGroup getLatestServer() { + return latestServer; + } + } + + public final class StateProcessorForGroup { + private final GroupMasterKey masterKey; + private final GroupId.V2 groupId; + private final GroupSecretParams groupSecretParams; + + private StateProcessorForGroup(@NonNull GroupMasterKey groupMasterKey) { + this.masterKey = groupMasterKey; + this.groupId = GroupId.v2(masterKey); + this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + } + + /** + * Using network where required, will attempt to bring the local copy of the group up to the revision specified. + * + * @param revision use {@link #LATEST} to get latest. + */ + @WorkerThread + public GroupUpdateResult updateLocalGroupToRevision(final int revision, + final long timestamp) + throws IOException, GroupNotAMemberException + { + if (localIsAtLeast(revision)) { + return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null); + } + + GlobalGroupState inputGroupState = queryServer(); + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision); + DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState(); + + if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) { + return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null); + } + + updateLocalDatabaseGroupState(inputGroupState, newLocalState); + insertUpdateMessages(timestamp, advanceGroupStateResult.getProcessedLogEntries()); + persistLearnedProfileKeys(inputGroupState); + + GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState(); + if (remainingWork.getHistory().size() > 0) { + Log.i(TAG, String.format(Locale.US, "There are more versions on the server for this group, not applying at this time, V[%d..%d]", newLocalState.getVersion() + 1, remainingWork.getLatestVersionNumber())); + } + + return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState); + } + + /** + * @return true iff group exists locally and is at least the specified revision. + */ + private boolean localIsAtLeast(int revision) { + if (groupDatabase.isUnknownGroup(groupId) || revision == LATEST) { + return false; + } + int dbRevision = groupDatabase.getGroup(groupId).get().requireV2GroupProperties().getGroupRevision(); + return revision <= dbRevision; + } + + private void updateLocalDatabaseGroupState(@NonNull GlobalGroupState inputGroupState, + @NonNull DecryptedGroup newLocalState) + { + if (inputGroupState.getLocalState() == null) { + groupDatabase.create(masterKey, newLocalState); + } else { + groupDatabase.update(masterKey, newLocalState); + } + + String avatar = newLocalState.getAvatar(); + if (!avatar.isEmpty()) { + jobManager.add(new AvatarGroupsV2DownloadJob(groupId, avatar)); + } + + final boolean fullMemberPostUpdate = GroupProtoUtil.isMember(Recipient.self().getUuid().get(), newLocalState.getMembersList()); + if (fullMemberPostUpdate) { + recipientDatabase.setProfileSharing(Recipient.externalGroup(context, groupId).getId(), true); + } + } + + private void insertUpdateMessages(long timestamp, Collection processedLogEntries) { + for (GroupLogEntry entry : processedLogEntries) { + storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, entry.getGroup(), entry.getChange()), timestamp); + } + } + + private void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) { + final ProfileKeySet profileKeys = new ProfileKeySet(); + + for (GroupLogEntry entry : globalGroupState.getHistory()) { + profileKeys.addKeysFromGroupState(entry.getGroup(), DecryptedGroupUtil.editorUuid(entry.getChange())); + } + + Collection updated = recipientDatabase.persistProfileKeySet(profileKeys); + + if (!updated.isEmpty()) { + Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, scheduling profile retrievals", updated.size())); + for (RecipientId recipient : updated) { + ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(recipient)); + } + } + } + + private GlobalGroupState queryServer() + throws IOException, GroupNotAMemberException + { + DecryptedGroup latestServerGroup; + List history; + UUID selfUuid = Recipient.self().getUuid().get(); + DecryptedGroup localState = groupDatabase.getGroup(groupId) + .transform(g -> g.requireV2GroupProperties().getDecryptedGroup()) + .orNull(); + + try { + latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization); + } catch (NotInGroupException e) { + throw new GroupNotAMemberException(e); + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new IOException(e); + } + + int versionWeWereAdded = GroupProtoUtil.findVersionWeWereAdded(latestServerGroup, selfUuid); + int logsNeededFrom = localState != null ? Math.max(localState.getVersion(), versionWeWereAdded) : versionWeWereAdded; + + if (GroupProtoUtil.isMember(selfUuid, latestServerGroup.getMembersList())) { + history = getFullMemberHistory(selfUuid, logsNeededFrom); + } else { + history = Collections.singletonList(new GroupLogEntry(latestServerGroup, null)); + } + + return new GlobalGroupState(localState, history); + } + + private List getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFrom) throws IOException { + try { + Collection groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFrom, groupsV2Authorization); + ArrayList history = new ArrayList<>(groupStatesFromRevision.size()); + + for (DecryptedGroupHistoryEntry entry : groupStatesFromRevision) { + history.add(new GroupLogEntry(entry.getGroup(), entry.getChange())); + } + + return history; + } catch (InvalidGroupStateException | VerificationFailedException e) { + throw new IOException(e); + } + } + + private void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) { + try { + MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); + RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupId); + Recipient recipient = Recipient.resolved(recipientId); + OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, decryptedGroupV2Context, null, timestamp, 0, false, null, Collections.emptyList(), Collections.emptyList()); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); + + mmsDatabase.markAsSent(messageId, true); + } catch (MmsException e) { + Log.w(TAG, e); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java index 98f0e10b81..0fbd45b72d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AvatarGroupsV2DownloadJob.java @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.jobs; import androidx.annotation.NonNull; -import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.groups.GroupSecretParams; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; @@ -47,7 +46,14 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { .setMaxAttempts(10) .build(), groupId, - cdnKey); + requireNonEmpty(cdnKey)); + } + + private static String requireNonEmpty(@NonNull String string) { + if (string.isEmpty()) { + throw new AssertionError(); + } + return string; } private AvatarGroupsV2DownloadJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, @NonNull String cdnKey) { @@ -102,7 +108,7 @@ public final class AvatarGroupsV2DownloadJob extends BaseJob { database.onAvatarUpdated(groupId, true); } - } catch (NonSuccessfulResponseCodeException | VerificationFailedException e) { + } catch (NonSuccessfulResponseCodeException e) { Log.w(TAG, e); } finally { if (attachment != null && attachment.exists()) 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 00450d5ff6..4f1cbdc45c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -90,6 +90,7 @@ public final class JobManagerFactories { put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory()); put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory()); put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory()); + put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); put(RotateCertificateJob.KEY, new RotateCertificateJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java new file mode 100644 index 0000000000..d61533d905 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoJob.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public final class RequestGroupV2InfoJob extends BaseJob { + + public static final String KEY = "RequestGroupV2InfoJob"; + + @SuppressWarnings("unused") + private static final String TAG = Log.tag(RequestGroupV2InfoJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + private static final String KEY_TO_REVISION = "to_revision"; + + private final GroupId.V2 groupId; + private final int toRevision; + + public RequestGroupV2InfoJob(@NonNull GroupId.V2 groupId, int toRevision) { + this(new Parameters.Builder() + .setQueue("RequestGroupV2InfoJob::" + groupId) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + groupId, + toRevision); + } + + /** + * Get latest group state for group. + */ + public RequestGroupV2InfoJob(@NonNull GroupId.V2 groupId) { + this(groupId, GroupsV2StateProcessor.LATEST); + } + + private RequestGroupV2InfoJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId, int toRevision) { + super(parameters); + + this.groupId = groupId; + this.toRevision = toRevision; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()) + .putInt(KEY_TO_REVISION, toRevision) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, GroupNotAMemberException, GroupChangeBusyException { + Log.i(TAG, "Updating group to revision " + toRevision); + + Optional group = DatabaseFactory.getGroupDatabase(context).getGroup(groupId); + + if (!group.isPresent()) { + Log.w(TAG, "Group not found"); + return; + } + + GroupManager.updateGroupFromServer(context, groupId, toRevision); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException || + e instanceof GroupChangeBusyException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull RequestGroupV2InfoJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new RequestGroupV2InfoJob(parameters, + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2(), + data.getInt(KEY_TO_REVISION)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index ec7f14f7e7..cc2d04c60e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -457,7 +457,13 @@ public class Util { public static void assertMainThread() { if (!isMainThread()) { - throw new AssertionError("Main-thread assertion failed."); + throw new AssertionError("Must run on main thread."); + } + } + + public static void assertNotMainThread() { + if (isMainThread()) { + throw new AssertionError("Cannot run on main thread."); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea628d0f5e..8f1c3139ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -502,6 +502,7 @@ You don\'t have the rights to do this Failed to update the group + You\'re not a member of the group Edit name and picture diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java index 620649873a..2ac6749223 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.java @@ -13,7 +13,6 @@ import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.signal.storageservice.protos.groups.AccessControl; -import org.signal.storageservice.protos.groups.DisappearingMessagesTimer; import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; @@ -21,6 +20,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; import org.signal.storageservice.protos.groups.local.DecryptedString; +import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.Arrays; @@ -538,7 +538,7 @@ public final class GroupsV2UpdateMessageProducerTest { } ChangeBuilder promote(@NonNull UUID pendingMember) { - builder.addPromotePendingMembers(UuidUtil.toByteString(pendingMember)); + builder.addPromotePendingMembers(DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(pendingMember))); return this; } @@ -555,8 +555,8 @@ public final class GroupsV2UpdateMessageProducerTest { } ChangeBuilder timer(int duration) { - builder.setNewTimer(DisappearingMessagesTimer.newBuilder() - .setDuration(duration)); + builder.setNewTimer(DecryptedTimer.newBuilder() + .setDuration(duration)); return this; } @@ -575,11 +575,11 @@ public final class GroupsV2UpdateMessageProducerTest { } } - private ChangeBuilder changeBy(@NonNull UUID groupEditor) { + private static ChangeBuilder changeBy(@NonNull UUID groupEditor) { return new ChangeBuilder(groupEditor); } - private @NonNull GroupsV2UpdateMessageProducer.DescribeMemberStrategy createDescriber(@NonNull Map map) { + private static @NonNull GroupsV2UpdateMessageProducer.DescribeMemberStrategy createDescriber(@NonNull Map map) { return uuid -> { String name = map.get(uuid); assertNotNull(name); diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupIdTest.java b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupIdTest.java index 90efd28f81..b919150abe 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/GroupIdTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/GroupIdTest.java @@ -58,7 +58,7 @@ public final class GroupIdTest { GroupId.V2 groupId = GroupId.v2(new GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))); - assertEquals("__signal_group__v2__!9f475f59b2518bff6df22e820803f0e3585bd99e686fa7e7fbfc2f92fd5d953e", groupId.toString()); + assertEquals("__signal_group__v2__!8c4a5ec277691282f64b965b1b9affc0285380c993c413f7560967d502dcf2e6", groupId.toString()); assertFalse(groupId.isMms()); assertFalse(groupId.isV1()); assertTrue(groupId.isV2()); diff --git a/app/witness-verifications.gradle b/app/witness-verifications.gradle index 10c4b30bdd..53f6fd3a07 100644 --- a/app/witness-verifications.gradle +++ b/app/witness-verifications.gradle @@ -372,11 +372,11 @@ dependencyVerification { ['org.signal:signal-metadata-java:0.1.2', '6aaeb6a33bf3161a3e6ac9db7678277f7a4cf5a2c96b84342e4007ee49bab1bd'], - ['org.signal:zkgroup-android:0.4.1', - '52049e207531ef50160873ad4a44682ce2bfb7706e5e4179035d9632eb9d5eac'], + ['org.signal:zkgroup-android:0.7.0', + '52b172565bd01526e93ebf1796b834bdc449d4fe3422c1b827e49cb8d4f13fbd'], - ['org.signal:zkgroup-java:0.4.1', - '12ea7e18c58aaefdbb8eccb748deff4f7c8fbd950eeb9c426dc894de50a83b77'], + ['org.signal:zkgroup-java:0.7.0', + 'd0099eedd60d6f7d4df5b288175e5d585228ed8897789926bdab69bf8c05659f'], ['org.threeten:threetenbp:1.3.6', 'f4c23ffaaed717c3b99c003e0ee02d6d66377fd47d866fec7d971bd8644fc1a7'], diff --git a/libsignal/service/build.gradle b/libsignal/service/build.gradle index 79aaee9c2e..a31b90b6fb 100644 --- a/libsignal/service/build.gradle +++ b/libsignal/service/build.gradle @@ -36,7 +36,7 @@ dependencies { api 'com.squareup.okhttp3:okhttp:3.12.10' implementation 'org.threeten:threetenbp:1.3.6' - api 'org.signal:zkgroup-java:0.4.1' + api 'org.signal:zkgroup-java:0.7.0' testImplementation 'junit:junit:4.12' testImplementation 'org.assertj:assertj-core:1.7.1' diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index 526f8250cb..13c2cb373a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -2,10 +2,13 @@ package org.whispersystems.signalservice.api.groupsv2; import com.google.protobuf.ByteString; +import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; import org.signal.zkgroup.util.UUIDUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.util.UuidUtil; @@ -14,6 +17,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -71,11 +75,11 @@ public final class DecryptedGroupUtil { * The UUID of the member that made the change. */ public static UUID editorUuid(DecryptedGroupChange change) { - return UuidUtil.fromByteString(change.getEditor()); + return change != null ? UuidUtil.fromByteStringOrUnknown(change.getEditor()) : UuidUtil.UNKNOWN_UUID; } public static Optional findMemberByUuid(Collection members, UUID uuid) { - ByteString uuidBytes = ByteString.copyFrom(UUIDUtil.serialize(uuid)); + ByteString uuidBytes = UuidUtil.toByteString(uuid); for (DecryptedMember member : members) { if (uuidBytes.equals(member.getUuid())) { @@ -87,7 +91,7 @@ public final class DecryptedGroupUtil { } public static Optional findPendingByUuid(Collection members, UUID uuid) { - ByteString uuidBytes = ByteString.copyFrom(UUIDUtil.serialize(uuid)); + ByteString uuidBytes = UuidUtil.toByteString(uuid); for (DecryptedPendingMember member : members) { if (uuidBytes.equals(member.getUuid())) { @@ -98,6 +102,28 @@ public final class DecryptedGroupUtil { return Optional.absent(); } + private static int findPendingIndexByUuidCipherText(List members, ByteString cipherText) { + for (int i = 0; i < members.size(); i++) { + DecryptedPendingMember member = members.get(i); + if (cipherText.equals(member.getUuidCipherText())) { + return i; + } + } + + return -1; + } + + private static int findPendingIndexByUuid(List members, ByteString uuid) { + for (int i = 0; i < members.size(); i++) { + DecryptedPendingMember member = members.get(i); + if (uuid.equals(member.getUuid())) { + return i; + } + } + + return -1; + } + /** * Removes the uuid from the full members of a group. *

@@ -127,4 +153,105 @@ public final class DecryptedGroupUtil { return group; } } + + public static DecryptedGroup apply(DecryptedGroup group, DecryptedGroupChange change) + throws NotAbleToApplyChangeException + { + if (change.getVersion() != group.getVersion() + 1) { + throw new NotAbleToApplyChangeException(); + } + + DecryptedGroup.Builder builder = DecryptedGroup.newBuilder(group); + + builder.addAllMembers(change.getNewMembersList()); + + for (ByteString removedMember : change.getDeleteMembersList()) { + int index = indexOfUuid(builder.getMembersList(), removedMember); + + if (index == -1) { + throw new NotAbleToApplyChangeException(); + } + + builder.removeMembers(index); + } + + for (DecryptedModifyMemberRole modifyMemberRole : change.getModifyMemberRolesList()) { + int index = indexOfUuid(builder.getMembersList(), modifyMemberRole.getUuid()); + + if (index == -1) { + throw new NotAbleToApplyChangeException(); + } + + builder.setMembers(index, DecryptedMember.newBuilder(builder.getMembers(index)).setRole(modifyMemberRole.getRole()).build()); + } + + for (DecryptedMember modifyProfileKey : change.getModifiedProfileKeysList()) { + int index = indexOfUuid(builder.getMembersList(), modifyProfileKey.getUuid()); + + if (index == -1) { + throw new NotAbleToApplyChangeException(); + } + + builder.setMembers(index, modifyProfileKey); + } + + for (DecryptedPendingMemberRemoval removedMember : change.getDeletePendingMembersList()) { + int index = findPendingIndexByUuidCipherText(builder.getPendingMembersList(), removedMember.getUuidCipherText()); + + if (index == -1) { + throw new NotAbleToApplyChangeException(); + } + + builder.removePendingMembers(index); + } + + for (DecryptedMember newMember : change.getPromotePendingMembersList()) { + int index = findPendingIndexByUuid(builder.getPendingMembersList(), newMember.getUuid()); + + if (index == -1) { + throw new NotAbleToApplyChangeException(); + } + + builder.removePendingMembers(index); + builder.addMembers(newMember); + } + + builder.addAllPendingMembers(change.getNewPendingMembersList()); + + if (change.hasNewTitle()) { + builder.setTitle(change.getNewTitle().getValue()); + } + + if (change.hasNewAvatar()) { + builder.setAvatar(change.getNewAvatar().getValue()); + } + + if (change.hasNewTimer()) { + builder.setDisappearingMessagesTimer(change.getNewTimer()); + } + + if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { + builder.setAccessControl(AccessControl.newBuilder(builder.getAccessControl()) + .setAttributesValue(change.getNewAttributeAccessValue()) + .build()); + } + + if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { + builder.setAccessControl(AccessControl.newBuilder(builder.getAccessControl()) + .setMembersValue(change.getNewMemberAccessValue()) + .build()); + } + + return builder.setVersion(change.getVersion()).build(); + } + + private static int indexOfUuid(List memberList, ByteString uuid) { + for (int i = 0; i < memberList.size(); i++) { + if(uuid.equals(memberList.get(i).getUuid())) return i; + } + return -1; + } + + public static class NotAbleToApplyChangeException extends Throwable { + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java index d781849ac8..8059c489c7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil.java @@ -169,12 +169,12 @@ public final class GroupChangeUtil { } private static void resolveField9PromotePendingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap pendingMembersByUuid) { - List promotePendingMembersList = conflictingChange.getPromotePendingMembersList(); + List promotePendingMembersList = conflictingChange.getPromotePendingMembersList(); for (int i = promotePendingMembersList.size() - 1; i >= 0; i--) { - ByteString member = promotePendingMembersList.get(i); + DecryptedMember member = promotePendingMembersList.get(i); - if (!pendingMembersByUuid.containsKey(member)) { + if (!pendingMembersByUuid.containsKey(member.getUuid())) { result.removePromotePendingMembers(i); } } 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 cda211ec91..ad727e9852 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 @@ -1,7 +1,10 @@ package org.whispersystems.signalservice.api.groupsv2; +import com.google.protobuf.ByteString; + import org.signal.storageservice.protos.groups.AvatarUploadAttributes; import org.signal.storageservice.protos.groups.Group; +import org.signal.storageservice.protos.groups.GroupAttributeBlob; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.GroupChanges; import org.signal.storageservice.protos.groups.local.DecryptedGroup; @@ -86,7 +89,7 @@ public final class GroupsV2Api { byte[] cipherText; try { - cipherText = new ClientZkGroupCipher(groupSecretParams).encryptBlob(avatar); + cipherText = new ClientZkGroupCipher(groupSecretParams).encryptBlob(GroupAttributeBlob.newBuilder().setAvatar(ByteString.copyFrom(avatar)).build().toByteArray()); } catch (VerificationFailedException e) { throw new AssertionError(e); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index 5d72397906..3da7987859 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -4,8 +4,8 @@ import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; import org.signal.storageservice.protos.groups.AccessControl; -import org.signal.storageservice.protos.groups.DisappearingMessagesTimer; import org.signal.storageservice.protos.groups.Group; +import org.signal.storageservice.protos.groups.GroupAttributeBlob; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.protos.groups.PendingMember; @@ -16,6 +16,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; import org.signal.storageservice.protos.groups.local.DecryptedString; +import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.NotarySignature; import org.signal.zkgroup.ServerPublicParams; @@ -30,10 +31,10 @@ import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.signal.zkgroup.profiles.ProfileKeyCredentialPresentation; import org.signal.zkgroup.util.UUIDUtil; +import org.whispersystems.libsignal.logging.Log; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.util.UuidUtil; -import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collections; @@ -46,8 +47,10 @@ import java.util.UUID; */ public final class GroupsV2Operations { + private static final String TAG = GroupsV2Operations.class.getSimpleName(); + /** Used for undecryptable pending invites */ - public static final UUID UNKNOWN_UUID = new UUID(0, 0); + public static final UUID UNKNOWN_UUID = UuidUtil.UNKNOWN_UUID; private final ServerPublicParams serverPublicParams; private final ClientZkProfileOperations clientZkProfileOperations; @@ -222,7 +225,7 @@ public final class GroupsV2Operations { } public DecryptedGroup decryptGroup(Group group) - throws VerificationFailedException, InvalidGroupStateException, InvalidProtocolBufferException + throws VerificationFailedException, InvalidGroupStateException { List membersList = group.getMembersList(); List pendingMembersList = group.getPendingMembersList(); @@ -237,20 +240,15 @@ public final class GroupsV2Operations { decryptedPendingMembers.add(decryptMember(member)); } - DecryptedGroup.Builder builder = DecryptedGroup.newBuilder() - .setTitle(decryptTitle(group.getTitle())) - .setAvatar(group.getAvatar()) - .setAccessControl(group.getAccessControl()) - .setVersion(group.getVersion()) - .addAllMembers(decryptedMembers) - .addAllPendingMembers(decryptedPendingMembers); - - DisappearingMessagesTimer messagesTimer = decryptDisappearingMessagesTimer(group.getDisappearingMessagesTimer()); - if (messagesTimer != null) { - builder.setDisappearingMessagesTimer(messagesTimer); - } - - return builder.build(); + return DecryptedGroup.newBuilder() + .setTitle(decryptTitle(group.getTitle())) + .setAvatar(group.getAvatar()) + .setAccessControl(group.getAccessControl()) + .setVersion(group.getVersion()) + .addAllMembers(decryptedMembers) + .addAllPendingMembers(decryptedPendingMembers) + .setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(decryptDisappearingMessagesTimer(group.getDisappearingMessagesTimer()))) + .build(); } /** @@ -261,7 +259,7 @@ public final class GroupsV2Operations { * are not signed, but should be empty. */ public DecryptedGroupChange decryptChange(GroupChange groupChange, boolean verify) - throws InvalidProtocolBufferException, VerificationFailedException, InvalidGroupStateException + throws InvalidProtocolBufferException, VerificationFailedException, InvalidGroupStateException { GroupChange.Actions actions = verify ? getVerifiedActions(groupChange) : getActions(groupChange); @@ -269,12 +267,22 @@ public final class GroupsV2Operations { } public DecryptedGroupChange decryptChange(GroupChange.Actions actions) - throws InvalidProtocolBufferException, VerificationFailedException, InvalidGroupStateException + throws VerificationFailedException, InvalidGroupStateException + { + return decryptChange(actions, null); + } + + public DecryptedGroupChange decryptChange(GroupChange.Actions actions, UUID source) + throws VerificationFailedException, InvalidGroupStateException { DecryptedGroupChange.Builder builder = DecryptedGroupChange.newBuilder(); // Field 1 - builder.setEditor(decryptUuidToByteString(actions.getSourceUuid())); + if (source != null) { + builder.setEditor(UuidUtil.toByteString(source)); + } else { + builder.setEditor(decryptUuidToByteString(actions.getSourceUuid())); + } // Field 2 builder.setVersion(actions.getVersion()); @@ -283,7 +291,9 @@ public final class GroupsV2Operations { for (GroupChange.Actions.AddMemberAction addMemberAction : actions.getAddMembersList()) { UUID uuid = decryptUuid(addMemberAction.getAdded().getUserId()); builder.addNewMembers(DecryptedMember.newBuilder() - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setJoinedAtVersion(actions.getVersion()) + .setRole(addMemberAction.getAdded().getRole()) + .setUuid(UuidUtil.toByteString(uuid)) .setProfileKey(decryptProfileKeyToByteString(addMemberAction.getAdded().getProfileKey(), uuid))); } @@ -309,7 +319,7 @@ public final class GroupsV2Operations { builder.addModifiedProfileKeys(DecryptedMember.newBuilder() .setRole(Member.Role.UNKNOWN) .setJoinedAtVersion(-1) - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setUuid(UuidUtil.toByteString(uuid)) .setProfileKey(ByteString.copyFrom(decryptProfileKey(ByteString.copyFrom(presentation.getProfileKeyCiphertext().serialize()), uuid).serialize()))); } catch (InvalidInputException e) { throw new InvalidGroupStateException(e); @@ -324,7 +334,7 @@ public final class GroupsV2Operations { UUID uuid = decryptUuidOrUnknown(uuidCipherText); builder.addNewPendingMembers(DecryptedPendingMember.newBuilder() - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setUuid(UuidUtil.toByteString(uuid)) .setUuidCipherText(uuidCipherText) .setRole(member.getRole()) .setAddedByUuid(decryptUuidToByteString(added.getAddedByUserId())) @@ -337,7 +347,7 @@ public final class GroupsV2Operations { UUID uuid = decryptUuidOrUnknown(uuidCipherText); builder.addDeletePendingMembers(DecryptedPendingMemberRemoval.newBuilder() - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setUuid(UuidUtil.toByteString(uuid)) .setUuidCipherText(uuidCipherText)); } @@ -349,8 +359,13 @@ public final class GroupsV2Operations { } catch (InvalidInputException e) { throw new InvalidGroupStateException(e); } - UUID uuid = clientZkGroupCipher.decryptUuid(profileKeyCredentialPresentation.getUuidCiphertext()); - builder.addPromotePendingMembers(UuidUtil.toByteString(uuid)); + UUID uuid = clientZkGroupCipher.decryptUuid(profileKeyCredentialPresentation.getUuidCiphertext()); + ProfileKey profileKey = clientZkGroupCipher.decryptProfileKey(profileKeyCredentialPresentation.getProfileKeyCiphertext(), uuid); + builder.addPromotePendingMembers(DecryptedMember.newBuilder() + .setJoinedAtVersion(-1) + .setRole(Member.Role.DEFAULT) + .setUuid(UuidUtil.toByteString(uuid)) + .setProfileKey(ByteString.copyFrom(profileKey.serialize()))); } // Field 10 @@ -365,8 +380,8 @@ public final class GroupsV2Operations { // Field 12 if (actions.hasModifyDisappearingMessagesTimer()) { - int duration = decryptDisappearingMessagesTimer(actions.getModifyDisappearingMessagesTimer().getTimer()).getDuration(); - builder.setNewTimer(DisappearingMessagesTimer.newBuilder().setDuration(duration)); + int duration = decryptDisappearingMessagesTimer(actions.getModifyDisappearingMessagesTimer().getTimer()); + builder.setNewTimer(DecryptedTimer.newBuilder().setDuration(duration)); } // Field 13 @@ -383,27 +398,28 @@ public final class GroupsV2Operations { } private DecryptedMember decryptMember(Member member) - throws InvalidGroupStateException, VerificationFailedException + throws InvalidGroupStateException, VerificationFailedException { ByteString userId = member.getUserId(); UUID uuid = decryptUuid(userId); return DecryptedMember.newBuilder() - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setUuid(UuidUtil.toByteString(uuid)) + .setJoinedAtVersion(member.getJoinedAtVersion()) .setProfileKey(decryptProfileKeyToByteString(member.getProfileKey(), uuid)) .setRole(member.getRole()) .build(); } private DecryptedPendingMember decryptMember(PendingMember member) - throws InvalidGroupStateException, VerificationFailedException + throws InvalidGroupStateException, VerificationFailedException { ByteString userIdCipherText = member.getMember().getUserId(); UUID uuid = decryptUuidOrUnknown(userIdCipherText); UUID addedBy = decryptUuid(member.getAddedByUserId()); return DecryptedPendingMember.newBuilder() - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setUuid(UuidUtil.toByteString(uuid)) .setUuidCipherText(userIdCipherText) .setAddedByUuid(ByteString.copyFrom(UUIDUtil.serialize(addedBy))) .setRole(member.getMember().getRole()) @@ -452,43 +468,52 @@ public final class GroupsV2Operations { private ByteString encryptTitle(String title) { try { - return ByteString.copyFrom(clientZkGroupCipher.encryptBlob((title == null ? "" : title).getBytes(StandardCharsets.UTF_8))); + GroupAttributeBlob blob = GroupAttributeBlob.newBuilder().setTitle(title).build(); + + return ByteString.copyFrom(clientZkGroupCipher.encryptBlob(blob.toByteArray())); } catch (VerificationFailedException e) { throw new AssertionError(e); } } - private String decryptTitle(ByteString cipherText) throws VerificationFailedException { - return new String(decryptBlob(cipherText), StandardCharsets.UTF_8); + private String decryptTitle(ByteString cipherText) { + return decryptBlob(cipherText).getTitle(); } - private DisappearingMessagesTimer decryptDisappearingMessagesTimer(ByteString encryptedTimerMessage) - throws VerificationFailedException, InvalidProtocolBufferException - { - return DisappearingMessagesTimer.parseFrom(decryptBlob(encryptedTimerMessage)); + private int decryptDisappearingMessagesTimer(ByteString encryptedTimerMessage) { + return decryptBlob(encryptedTimerMessage).getDisappearingMessagesDuration(); } - private byte[] decryptBlob(ByteString blob) throws VerificationFailedException { + public byte[] decryptAvatar(byte[] bytes) { + return decryptBlob(bytes).getAvatar().toByteArray(); + } + + private GroupAttributeBlob decryptBlob(ByteString blob) { return decryptBlob(blob.toByteArray()); } - public byte[] decryptAvatar(byte[] bytes) throws VerificationFailedException { - return decryptBlob(bytes); - } - - private byte[] decryptBlob(byte[] bytes) throws VerificationFailedException { + private GroupAttributeBlob decryptBlob(byte[] bytes) { // TODO GV2: Minimum field length checking should be responsibility of clientZkGroupCipher#decryptBlob - if (bytes == null) return null; - if (bytes.length == 0) return bytes; - if (bytes.length < 28) throw new VerificationFailedException(); - return clientZkGroupCipher.decryptBlob(bytes); + if (bytes == null || bytes.length == 0) { + return GroupAttributeBlob.getDefaultInstance(); + } + if (bytes.length < 29) { + Log.w(TAG, "Bad encrypted blob length"); + return GroupAttributeBlob.getDefaultInstance(); + } + try { + return GroupAttributeBlob.parseFrom(clientZkGroupCipher.decryptBlob(bytes)); + } catch (InvalidProtocolBufferException | VerificationFailedException e) { + Log.w(TAG, "Bad encrypted blob"); + return GroupAttributeBlob.getDefaultInstance(); + } } private ByteString encryptTimer(int timerDurationSeconds) { try { - DisappearingMessagesTimer timer = DisappearingMessagesTimer.newBuilder() - .setDuration(timerDurationSeconds) - .build(); + GroupAttributeBlob timer = GroupAttributeBlob.newBuilder() + .setDisappearingMessagesDuration(timerDurationSeconds) + .build(); return ByteString.copyFrom(clientZkGroupCipher.encryptBlob(timer.toByteArray())); } catch (VerificationFailedException e) { throw new AssertionError(e); @@ -499,7 +524,7 @@ public final class GroupsV2Operations { * Verifies signature and parses actions on a group change. */ private GroupChange.Actions getVerifiedActions(GroupChange groupChange) - throws VerificationFailedException, InvalidProtocolBufferException + throws VerificationFailedException, InvalidProtocolBufferException { byte[] actionsByteArray = groupChange.getActions().toByteArray(); @@ -519,7 +544,7 @@ public final class GroupsV2Operations { * Parses actions on a group change without verification. */ private GroupChange.Actions getActions(GroupChange groupChange) - throws InvalidProtocolBufferException + throws InvalidProtocolBufferException { return GroupChange.Actions.parseFrom(groupChange.getActions()); } @@ -535,6 +560,13 @@ public final class GroupsV2Operations { .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.newBuilder() .setAttributesAccess(newRights)); } + + public GroupChange.Actions.Builder createChangeMemberRole(UUID uuid, Member.Role role) { + return GroupChange.Actions.newBuilder() + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder() + .setUserId(encryptUuid(uuid)) + .setRole(role)); + } } public static class NewGroup { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java index 7eab553a82..83967f3951 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java @@ -13,6 +13,8 @@ import java.util.regex.Pattern; public final class UuidUtil { + public static final UUID UNKNOWN_UUID = new UUID(0, 0); + private static final Pattern UUID_PATTERN = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", Pattern.CASE_INSENSITIVE); private UuidUtil() { } @@ -57,6 +59,19 @@ public final class UuidUtil { return parseOrThrow(bytes.toByteArray()); } + public static UUID fromByteStringOrNull(ByteString bytes) { + return parseOrNull(bytes.toByteArray()); + } + + public static UUID fromByteStringOrUnknown(ByteString bytes) { + UUID uuid = parseOrNull(bytes.toByteArray()); + return uuid != null ? uuid : UNKNOWN_UUID; + } + + private static UUID parseOrNull(byte[] byteArray) { + return byteArray != null && byteArray.length == 16 ? parseOrThrow(byteArray) : null; + } + public static List fromByteStrings(Collection byteStringCollection) { ArrayList result = new ArrayList<>(byteStringCollection.size()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 19e98a14eb..427080cdcd 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -73,6 +73,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse; import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; +import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException; import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody; @@ -1551,6 +1552,12 @@ public class PushServiceSocket { private ResponseBody makeStorageRequest(String authorization, String path, String method, RequestBody body) throws PushNetworkException, NonSuccessfulResponseCodeException + { + return makeStorageRequest(authorization, path, method, body, NO_HANDLER); + } + + private ResponseBody makeStorageRequest(String authorization, String path, String method, RequestBody body, ResponseCodeHandler responseCodeHandler) + throws PushNetworkException, NonSuccessfulResponseCodeException { ConnectionHolder connectionHolder = getRandom(storageClients, random); OkHttpClient okHttpClient = connectionHolder.getClient() @@ -1594,6 +1601,8 @@ public class PushServiceSocket { } } + responseCodeHandler.handle(response.code()); + switch (response.code()) { case 204: throw new NoContentException("No content!"); @@ -1826,73 +1835,69 @@ public class PushServiceSocket { return JsonUtil.fromJson(response, CredentialResponse.class); } + private static final ResponseCodeHandler GROUPS_V2_PUT_RESPONSE_HANDLER = NO_HANDLER; + private static final ResponseCodeHandler GROUPS_V2_PATCH_RESPONSE_HANDLER = NO_HANDLER; + private static final ResponseCodeHandler GROUPS_V2_GET_LOGS_HANDLER = NO_HANDLER; + private static final ResponseCodeHandler GROUPS_V2_GET_CURRENT_HANDLER = responseCode -> { + if (responseCode == 403) throw new NotInGroupException(); + }; + public void putNewGroupsV2Group(Group group, String authorization) throws NonSuccessfulResponseCodeException, PushNetworkException { makeStorageRequest(authorization, GROUPSV2_GROUP, "PUT", - protobufRequestBody(group)); + protobufRequestBody(group), + GROUPS_V2_PUT_RESPONSE_HANDLER); } public Group getGroupsV2Group(String authorization) - throws IOException + throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException { ResponseBody response = makeStorageRequest(authorization, GROUPSV2_GROUP, "GET", - null); + null, + GROUPS_V2_GET_CURRENT_HANDLER); - try { - return Group.parseFrom(readBodyBytes(response)); - } catch (InvalidProtocolBufferException e) { - throw new IOException("Cannot read protobuf", e); - } + return Group.parseFrom(readBodyBytes(response)); } public AvatarUploadAttributes getGroupsV2AvatarUploadForm(String authorization) - throws IOException + throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException { ResponseBody response = makeStorageRequest(authorization, GROUPSV2_AVATAR_REQUEST, "GET", - null); + null, + NO_HANDLER); - try { - return AvatarUploadAttributes.parseFrom(readBodyBytes(response)); - } catch (InvalidProtocolBufferException e) { - throw new IOException("Cannot read protobuf", e); - } + return AvatarUploadAttributes.parseFrom(readBodyBytes(response)); } public GroupChange patchGroupsV2Group(GroupChange.Actions groupChange, String authorization) - throws IOException + throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException { ResponseBody response = makeStorageRequest(authorization, GROUPSV2_GROUP, "PATCH", - protobufRequestBody(groupChange)); + protobufRequestBody(groupChange), + GROUPS_V2_PATCH_RESPONSE_HANDLER); - try { - return GroupChange.parseFrom(readBodyBytes(response)); - } catch (InvalidProtocolBufferException e) { - throw new IOException("Cannot read protobuf", e); - } + return GroupChange.parseFrom(readBodyBytes(response)); } public GroupChanges getGroupsV2GroupHistory(int fromVersion, String authorization) - throws IOException + throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException { ResponseBody response = makeStorageRequest(authorization, String.format(Locale.US, GROUPSV2_GROUP_CHANGES, fromVersion), "GET", - null); + null, + GROUPS_V2_GET_LOGS_HANDLER); - try { - return GroupChanges.parseFrom(readBodyBytes(response)); - } catch (InvalidProtocolBufferException e) { - throw new IOException("Cannot read protobuf", e); - } + return GroupChanges.parseFrom(readBodyBytes(response)); } private final class ResumeInfo { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/NotInGroupException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/NotInGroupException.java new file mode 100644 index 0000000000..eb7502b66e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/NotInGroupException.java @@ -0,0 +1,6 @@ +package org.whispersystems.signalservice.internal.push.exceptions; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +public final class NotInGroupException extends NonSuccessfulResponseCodeException { +} diff --git a/libsignal/service/src/main/proto/DecryptedGroups.proto b/libsignal/service/src/main/proto/DecryptedGroups.proto index d8c71b9df3..5445cb0e4b 100644 --- a/libsignal/service/src/main/proto/DecryptedGroups.proto +++ b/libsignal/service/src/main/proto/DecryptedGroups.proto @@ -40,13 +40,13 @@ message DecryptedModifyMemberRole { // Decrypted version of message Group // Keep field numbers in step message DecryptedGroup { - string title = 2; - string avatar = 3; - DisappearingMessagesTimer disappearingMessagesTimer = 4; - AccessControl accessControl = 5; - uint32 version = 6; - repeated DecryptedMember members = 7; - repeated DecryptedPendingMember pendingMembers = 8; + string title = 2; + string avatar = 3; + DecryptedTimer disappearingMessagesTimer = 4; + AccessControl accessControl = 5; + uint32 version = 6; + repeated DecryptedMember members = 7; + repeated DecryptedPendingMember pendingMembers = 8; } // Decrypted version of message GroupChange.Actions @@ -60,10 +60,10 @@ message DecryptedGroupChange { repeated DecryptedMember modifiedProfileKeys = 6; repeated DecryptedPendingMember newPendingMembers = 7; repeated DecryptedPendingMemberRemoval deletePendingMembers = 8; - repeated bytes promotePendingMembers = 9; + repeated DecryptedMember promotePendingMembers = 9; DecryptedString newTitle = 10; DecryptedString newAvatar = 11; - DisappearingMessagesTimer newTimer = 12; + DecryptedTimer newTimer = 12; AccessControl.AccessRequired newAttributeAccess = 13; AccessControl.AccessRequired newMemberAccess = 14; } @@ -71,3 +71,7 @@ message DecryptedGroupChange { message DecryptedString { string value = 1; } + +message DecryptedTimer { + uint32 duration = 1; +} diff --git a/libsignal/service/src/main/proto/Groups.proto b/libsignal/service/src/main/proto/Groups.proto index 47057b56f4..49aa175726 100644 --- a/libsignal/service/src/main/proto/Groups.proto +++ b/libsignal/service/src/main/proto/Groups.proto @@ -143,6 +143,10 @@ message GroupChanges { repeated GroupChangeState groupChanges = 1; } -message DisappearingMessagesTimer { - uint32 duration = 1; +message GroupAttributeBlob { + oneof content { + string title = 1; + bytes avatar = 2; + uint32 disappearingMessagesDuration = 3; + } } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtilTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtilTest.java index a8983de977..6eacc3750a 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtilTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtilTest.java @@ -6,6 +6,7 @@ import org.junit.Test; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.zkgroup.util.UUIDUtil; +import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.UUID; @@ -17,7 +18,7 @@ public final class DecryptedGroupUtilTest { public void can_extract_uuid_from_decrypted_member() { UUID uuid = UUID.randomUUID(); DecryptedMember decryptedMember = DecryptedMember.newBuilder() - .setUuid(ByteString.copyFrom(UUIDUtil.serialize(uuid))) + .setUuid(UuidUtil.toByteString(uuid)) .build(); UUID parsed = DecryptedGroupUtil.toUuid(decryptedMember); @@ -28,7 +29,7 @@ public final class DecryptedGroupUtilTest { @Test public void can_extract_editor_uuid_from_decrypted_group_change() { UUID uuid = UUID.randomUUID(); - ByteString editor = ByteString.copyFrom(UUIDUtil.serialize(uuid)); + ByteString editor = UuidUtil.toByteString(uuid); DecryptedGroupChange groupChange = DecryptedGroupChange.newBuilder() .setEditor(editor) .build(); diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java new file mode 100644 index 0000000000..0d63aa2e70 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java @@ -0,0 +1,398 @@ +package org.whispersystems.signalservice.api.groupsv2; + +import org.junit.Test; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.signal.storageservice.protos.groups.local.DecryptedString; +import org.signal.storageservice.protos.groups.local.DecryptedTimer; +import org.signal.zkgroup.profiles.ProfileKey; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.asAdmin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.asMember; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey; + +public final class DecryptedGroupUtil_apply_Test { + + @Test + public void apply_version() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(9) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(10) + .build()); + + assertEquals(10, newGroup.getVersion()); + } + + @Test + public void apply_new_member() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + DecryptedMember member2 = member(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .addMembers(member1) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .addNewMembers(member2) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .addMembers(member1) + .addMembers(member2) + .build(), + newGroup); + } + + @Test + public void apply_remove_member() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + DecryptedMember member2 = member(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(13) + .addMembers(member1) + .addMembers(member2) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(14) + .addDeleteMembers(member1.getUuid()) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(14) + .addMembers(member2) + .build(), + newGroup); + } + + @Test + public void apply_remove_members() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + DecryptedMember member2 = member(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(13) + .addMembers(member1) + .addMembers(member2) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(14) + .addDeleteMembers(member1.getUuid()) + .addDeleteMembers(member2.getUuid()) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(14) + .build(), + newGroup); + } + + @Test(expected = DecryptedGroupUtil.NotAbleToApplyChangeException.class) + public void apply_remove_members_not_found() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + DecryptedMember member2 = member(UUID.randomUUID()); + + DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(13) + .addMembers(member1) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(14) + .addDeleteMembers(member2.getUuid()) + .build()); + } + + @Test + public void apply_modify_member_role() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + DecryptedMember member2 = admin(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(13) + .addMembers(member1) + .addMembers(member2) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(14) + .addModifyMemberRoles(DecryptedModifyMemberRole.newBuilder().setUuid(member1.getUuid()).setRole(Member.Role.ADMINISTRATOR)) + .addModifyMemberRoles(DecryptedModifyMemberRole.newBuilder().setUuid(member2.getUuid()).setRole(Member.Role.DEFAULT)) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(14) + .addMembers(asAdmin(member1)) + .addMembers(asMember(member2)) + .build(), + newGroup); + } + + @Test + public void apply_modify_member_profile_keys() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + ProfileKey profileKey1 = randomProfileKey(); + ProfileKey profileKey2a = randomProfileKey(); + ProfileKey profileKey2b = randomProfileKey(); + DecryptedMember member1 = member(UUID.randomUUID(), profileKey1); + DecryptedMember member2a = member(UUID.randomUUID(), profileKey2a); + DecryptedMember member2b = withProfileKey(member2a, profileKey2b); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(13) + .addMembers(member1) + .addMembers(member2a) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(14) + .addModifiedProfileKeys(member2b) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(14) + .addMembers(member1) + .addMembers(member2b) + .build(), + newGroup); + } + + @Test + public void apply_new_pending_member() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + DecryptedPendingMember pending = pendingMember(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .addMembers(member1) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .addNewPendingMembers(pending) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .addMembers(member1) + .addPendingMembers(pending) + .build(), + newGroup); + } + + @Test + public void remove_pending_member() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedMember member1 = member(UUID.randomUUID()); + UUID pendingUuid = UUID.randomUUID(); + DecryptedPendingMember pending = pendingMember(pendingUuid); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .addMembers(member1) + .addPendingMembers(pending) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .addDeletePendingMembers(DecryptedPendingMemberRemoval.newBuilder() + .setUuidCipherText(ProtoTestUtils.encrypt(pendingUuid)) + .build()) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .addMembers(member1) + .build(), + newGroup); + } + + @Test + public void promote_pending_member() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + ProfileKey profileKey2 = randomProfileKey(); + DecryptedMember member1 = member(UUID.randomUUID()); + UUID pending2Uuid = UUID.randomUUID(); + DecryptedPendingMember pending2 = pendingMember(pending2Uuid); + DecryptedMember member2 = member(pending2Uuid, profileKey2); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .addMembers(member1) + .addPendingMembers(pending2) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .addPromotePendingMembers(member2) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .addMembers(member1) + .addMembers(member2) + .build(), + newGroup); + } + + @Test + public void promote_direct_to_admin() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + ProfileKey profileKey2 = randomProfileKey(); + DecryptedMember member1 = member(UUID.randomUUID()); + UUID pending2Uuid = UUID.randomUUID(); + DecryptedPendingMember pending2 = pendingMember(pending2Uuid); + DecryptedMember member2 = withProfileKey(admin(pending2Uuid), profileKey2); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .addMembers(member1) + .addPendingMembers(pending2) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .addPromotePendingMembers(member2) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .addMembers(member1) + .addMembers(member2) + .build(), + newGroup); + } + + @Test + public void title() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .setTitle("Old title") + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .setNewTitle(DecryptedString.newBuilder().setValue("New title").build()) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .setTitle("New title") + .build(), + newGroup); + } + + @Test + public void avatar() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .setAvatar("https://cnd/oldavatar") + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .setNewAvatar(DecryptedString.newBuilder().setValue("https://cnd/newavatar").build()) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .setAvatar("https://cnd/newavatar") + .build(), + newGroup); + } + + @Test + public void timer() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(100)) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .setNewTimer(DecryptedTimer.newBuilder().setDuration(2000)) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(2000)) + .build(), + newGroup); + } + + @Test + public void attribute_access() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.ADMINISTRATOR) + .setMembers(AccessControl.AccessRequired.MEMBER) + .build()) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .setNewAttributeAccess(AccessControl.AccessRequired.MEMBER) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.MEMBER) + .setMembers(AccessControl.AccessRequired.MEMBER) + .build()) + .build(), + newGroup); + } + + @Test + public void membership_access() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.ADMINISTRATOR) + .setMembers(AccessControl.AccessRequired.MEMBER) + .build()) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.ADMINISTRATOR) + .setMembers(AccessControl.AccessRequired.ADMINISTRATOR) + .build()) + .build(), + newGroup); + } + + @Test + public void change_both_access_levels() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setVersion(10) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.ADMINISTRATOR) + .setMembers(AccessControl.AccessRequired.MEMBER) + .build()) + .build(), + DecryptedGroupChange.newBuilder() + .setVersion(11) + .setNewAttributeAccess(AccessControl.AccessRequired.MEMBER) + .setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setVersion(11) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.MEMBER) + .setMembers(AccessControl.AccessRequired.ADMINISTRATOR) + .build()) + .build(), + newGroup); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java index 3a4b1fb398..6fa0867bfe 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_resolveConflict_Test.java @@ -4,27 +4,30 @@ import com.google.protobuf.ByteString; import org.junit.Test; import org.signal.storageservice.protos.groups.AccessControl; -import org.signal.storageservice.protos.groups.DisappearingMessagesTimer; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.protos.groups.PendingMember; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; -import org.signal.storageservice.protos.groups.local.DecryptedMember; -import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; -import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; -import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; import org.signal.storageservice.protos.groups.local.DecryptedString; -import org.signal.zkgroup.InvalidInputException; +import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.util.UuidUtil; -import java.security.SecureRandom; -import java.util.Arrays; import java.util.UUID; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.encrypt; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.encryptedMember; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.presentation; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey; public final class GroupChangeUtil_resolveConflict_Test { @@ -257,9 +260,9 @@ public final class GroupChangeUtil_resolveConflict_Test { .addPendingMembers(pendingMember(member2)) .build(); DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() - .addPromotePendingMembers(UuidUtil.toByteString(member1)) - .addPromotePendingMembers(UuidUtil.toByteString(member2)) - .addPromotePendingMembers(UuidUtil.toByteString(member3)) + .addPromotePendingMembers(member(member1)) + .addPromotePendingMembers(member(member2)) + .addPromotePendingMembers(member(member3)) .build(); GroupChange.Actions change = GroupChange.Actions.newBuilder() .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member1, randomProfileKey()))) @@ -370,10 +373,10 @@ public final class GroupChangeUtil_resolveConflict_Test { @Test public void field_12__timer_change_is_preserved() { DecryptedGroup groupState = DecryptedGroup.newBuilder() - .setDisappearingMessagesTimer(DisappearingMessagesTimer.newBuilder().setDuration(123)) + .setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(123)) .build(); DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() - .setNewTimer(DisappearingMessagesTimer.newBuilder().setDuration(456)) + .setNewTimer(DecryptedTimer.newBuilder().setDuration(456)) .build(); GroupChange.Actions change = GroupChange.Actions.newBuilder() .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder().setTimer(ByteString.EMPTY)) @@ -387,10 +390,10 @@ public final class GroupChangeUtil_resolveConflict_Test { @Test public void field_12__no_timer_change_is_removed() { DecryptedGroup groupState = DecryptedGroup.newBuilder() - .setDisappearingMessagesTimer(DisappearingMessagesTimer.newBuilder().setDuration(123)) + .setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(123)) .build(); DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() - .setNewTimer(DisappearingMessagesTimer.newBuilder().setDuration(123)) + .setNewTimer(DecryptedTimer.newBuilder().setDuration(123)) .build(); GroupChange.Actions change = GroupChange.Actions.newBuilder() .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder().setTimer(ByteString.EMPTY)) @@ -468,92 +471,4 @@ public final class GroupChangeUtil_resolveConflict_Test { assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); } - - private static ProfileKey randomProfileKey() { - byte[] contents = new byte[32]; - new SecureRandom().nextBytes(contents); - try { - return new ProfileKey(contents); - } catch (InvalidInputException e) { - throw new AssertionError(); - } - } - - /** - * Emulates encryption by creating a unique {@link ByteString} that won't equal a byte string created from the {@link UUID}. - */ - private static ByteString encrypt(UUID uuid) { - byte[] uuidBytes = UuidUtil.toByteArray(uuid); - return ByteString.copyFrom(Arrays.copyOf(uuidBytes, uuidBytes.length + 1)); - } - - /** - * Emulates a presentation by concatenating the uuid and profile key which makes it suitable for - * equality assertions in these tests. - */ - private static ByteString presentation(UUID uuid, ProfileKey profileKey) { - byte[] uuidBytes = UuidUtil.toByteArray(uuid); - byte[] profileKeyBytes = profileKey.serialize(); - byte[] concat = new byte[uuidBytes.length + profileKeyBytes.length]; - - System.arraycopy(uuidBytes, 0, concat, 0, uuidBytes.length); - System.arraycopy(profileKeyBytes, 0, concat, uuidBytes.length, profileKeyBytes.length); - - return ByteString.copyFrom(concat); - } - - private static DecryptedModifyMemberRole promoteAdmin(UUID member) { - return DecryptedModifyMemberRole.newBuilder() - .setUuid(UuidUtil.toByteString(member)) - .setRole(Member.Role.ADMINISTRATOR) - .build(); - } - - private static DecryptedModifyMemberRole demoteAdmin(UUID member) { - return DecryptedModifyMemberRole.newBuilder() - .setUuid(UuidUtil.toByteString(member)) - .setRole(Member.Role.DEFAULT) - .build(); - } - - private Member encryptedMember(UUID uuid, ProfileKey profileKey) { - return Member.newBuilder() - .setPresentation(presentation(uuid, profileKey)) - .build(); - } - - private static DecryptedMember member(UUID uuid) { - return DecryptedMember.newBuilder() - .setUuid(UuidUtil.toByteString(uuid)) - .setRole(Member.Role.DEFAULT) - .build(); - } - - private static DecryptedPendingMemberRemoval pendingMemberRemoval(UUID uuid) { - return DecryptedPendingMemberRemoval.newBuilder() - .setUuid(UuidUtil.toByteString(uuid)) - .build(); - } - - private static DecryptedPendingMember pendingMember(UUID uuid) { - return DecryptedPendingMember.newBuilder() - .setUuid(UuidUtil.toByteString(uuid)) - .setRole(Member.Role.DEFAULT) - .build(); - } - - private static DecryptedMember member(UUID uuid, ProfileKey profileKey) { - return DecryptedMember.newBuilder() - .setUuid(UuidUtil.toByteString(uuid)) - .setRole(Member.Role.DEFAULT) - .setProfileKey(ByteString.copyFrom(profileKey.serialize())) - .build(); - } - - private static DecryptedMember admin(UUID uuid) { - return DecryptedMember.newBuilder() - .setUuid(UuidUtil.toByteString(uuid)) - .setRole(Member.Role.ADMINISTRATOR) - .build(); - } } \ No newline at end of file diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java new file mode 100644 index 0000000000..2cbb21892d --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java @@ -0,0 +1,125 @@ +package org.whispersystems.signalservice.api.groupsv2; + +import com.google.protobuf.ByteString; + +import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedMember; +import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.UUID; + +final class ProtoTestUtils { + + static ProfileKey randomProfileKey() { + byte[] contents = new byte[32]; + new SecureRandom().nextBytes(contents); + try { + return new ProfileKey(contents); + } catch (InvalidInputException e) { + throw new AssertionError(); + } + } + + /** + * Emulates encryption by creating a unique {@link ByteString} that won't equal a byte string created from the {@link UUID}. + */ + static ByteString encrypt(UUID uuid) { + byte[] uuidBytes = UuidUtil.toByteArray(uuid); + return ByteString.copyFrom(Arrays.copyOf(uuidBytes, uuidBytes.length + 1)); + } + + /** + * Emulates a presentation by concatenating the uuid and profile key which makes it suitable for + * equality assertions in these tests. + */ + static ByteString presentation(UUID uuid, ProfileKey profileKey) { + byte[] uuidBytes = UuidUtil.toByteArray(uuid); + byte[] profileKeyBytes = profileKey.serialize(); + byte[] concat = new byte[uuidBytes.length + profileKeyBytes.length]; + + System.arraycopy(uuidBytes, 0, concat, 0, uuidBytes.length); + System.arraycopy(profileKeyBytes, 0, concat, uuidBytes.length, profileKeyBytes.length); + + return ByteString.copyFrom(concat); + } + + static DecryptedModifyMemberRole promoteAdmin(UUID member) { + return DecryptedModifyMemberRole.newBuilder() + .setUuid(UuidUtil.toByteString(member)) + .setRole(Member.Role.ADMINISTRATOR) + .build(); + } + + static DecryptedModifyMemberRole demoteAdmin(UUID member) { + return DecryptedModifyMemberRole.newBuilder() + .setUuid(UuidUtil.toByteString(member)) + .setRole(Member.Role.DEFAULT) + .build(); + } + + static Member encryptedMember(UUID uuid, ProfileKey profileKey) { + return Member.newBuilder() + .setPresentation(presentation(uuid, profileKey)) + .build(); + } + + static DecryptedMember member(UUID uuid) { + return DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(Member.Role.DEFAULT) + .build(); + } + + static DecryptedPendingMemberRemoval pendingMemberRemoval(UUID uuid) { + return DecryptedPendingMemberRemoval.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setUuidCipherText(encrypt(uuid)) + .build(); + } + + static DecryptedPendingMember pendingMember(UUID uuid) { + return DecryptedPendingMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setUuidCipherText(encrypt(uuid)) + .setRole(Member.Role.DEFAULT) + .build(); + } + + static DecryptedMember member(UUID uuid, ProfileKey profileKey) { + return withProfileKey(member(uuid), profileKey); + } + + static DecryptedMember admin(UUID uuid) { + return DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(Member.Role.ADMINISTRATOR) + .build(); + } + + static DecryptedMember withProfileKey(DecryptedMember member, ProfileKey profileKey) { + return DecryptedMember.newBuilder(member) + .setProfileKey(ByteString.copyFrom(profileKey.serialize())) + .build(); + } + + static DecryptedMember asAdmin(DecryptedMember member) { + return DecryptedMember.newBuilder() + .setUuid(member.getUuid()) + .setRole(Member.Role.ADMINISTRATOR) + .build(); + } + + static DecryptedMember asMember(DecryptedMember member) { + return DecryptedMember.newBuilder() + .setUuid(member.getUuid()) + .setRole(Member.Role.DEFAULT) + .build(); + } +} diff --git a/libsignal/service/witness-verifications.gradle b/libsignal/service/witness-verifications.gradle index a64f11daa6..ef189fae61 100644 --- a/libsignal/service/witness-verifications.gradle +++ b/libsignal/service/witness-verifications.gradle @@ -27,8 +27,8 @@ dependencyVerification { ['org.signal:signal-metadata-java:0.1.2', '6aaeb6a33bf3161a3e6ac9db7678277f7a4cf5a2c96b84342e4007ee49bab1bd'], - ['org.signal:zkgroup-java:0.4.1', - '12ea7e18c58aaefdbb8eccb748deff4f7c8fbd950eeb9c426dc894de50a83b77'], + ['org.signal:zkgroup-java:0.7.0', + 'd0099eedd60d6f7d4df5b288175e5d585228ed8897789926bdab69bf8c05659f'], ['org.threeten:threetenbp:1.3.6', 'f4c23ffaaed717c3b99c003e0ee02d6d66377fd47d866fec7d971bd8644fc1a7'],