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 f2e30fe8b..db52f3d0a 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 @@ -9,12 +9,14 @@ import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil; import org.thoughtcrime.securesms.util.ExpirationUtil; @@ -93,6 +95,10 @@ final class GroupsV2UpdateMessageProducer { describeUnknownEditorNewTimer(change, updates); describeUnknownEditorNewAttributeAccess(change, updates); describeUnknownEditorNewMembershipAccess(change, updates); + describeUnknownEditorNewGroupInviteLinkAccess(change, updates); + describeRequestingMembers(change, updates); + describeUnknownEditorRequestingMembersApprovals(change, updates); + describeUnknownEditorRequestingMembersDeletes(change, updates); describeUnknownEditorMemberRemovals(change, updates); @@ -112,6 +118,10 @@ final class GroupsV2UpdateMessageProducer { describeNewTimer(change, updates); describeNewAttributeAccess(change, updates); describeNewMembershipAccess(change, updates); + describeNewGroupInviteLinkAccess(change, updates); + describeRequestingMembers(change, updates); + describeRequestingMembersApprovals(change, updates); + describeRequestingMembersDeletes(change, updates); describeMemberRemovals(change, updates); @@ -148,7 +158,7 @@ final class GroupsV2UpdateMessageProducer { if (editorIsYou) { if (newMemberIsYou) { - updates.add(0, updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group))); + updates.add(0, updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group_via_the_sharable_group_link))); } else { updates.add(updateDescription(member.getUuid(), added -> context.getString(R.string.MessageRecord_you_added_s, added))); } @@ -157,7 +167,7 @@ final class GroupsV2UpdateMessageProducer { updates.add(0, updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor))); } else { if (member.getUuid().equals(change.getEditor())) { - updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group, newMember))); + updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group_via_the_sharable_group_link, newMember))); } else { updates.add(updateDescription(change.getEditor(), member.getUuid(), (editor, newMember) -> context.getString(R.string.MessageRecord_s_added_s, editor, newMember))); } @@ -498,6 +508,123 @@ final class GroupsV2UpdateMessageProducer { } } + private void describeNewGroupInviteLinkAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + boolean groupLinkEnabled = false; + + switch (change.getNewInviteLinkAccess()) { + case ANY: + groupLinkEnabled = true; + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_sharable_group_link))); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_sharable_group_link, editor))); + } + break; + case ADMINISTRATOR: + groupLinkEnabled = true; + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_on_the_sharable_group_link_with_admin_approval))); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_on_the_sharable_group_link_with_admin_approval, editor))); + } + break; + case UNSATISFIABLE: + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_turned_off_the_sharable_group_link))); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_turned_off_the_sharable_group_link, editor))); + } + break; + } + + if (!groupLinkEnabled && change.getNewInviteLinkPassword().size() > 0) { + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_reset_the_sharable_group_link))); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_reset_the_sharable_group_link, editor))); + } + } + } + + private void describeUnknownEditorNewGroupInviteLinkAccess(@NonNull DecryptedGroupChange change, @NonNull List updates) { + switch (change.getNewInviteLinkAccess()) { + case ANY: + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_sharable_group_link_has_been_turned_on))); + break; + case ADMINISTRATOR: + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_sharable_group_link_has_been_turned_on_with_admin_approval))); + break; + case UNSATISFIABLE: + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_sharable_group_link_has_been_turned_off))); + break; + } + + if (change.getNewInviteLinkPassword().size() > 0) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_sharable_group_link_has_been_reset))); + } + } + + private void describeRequestingMembers(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedRequestingMember member : change.getNewRequestingMembersList()) { + boolean requestingMemberIsYou = member.getUuid().equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_sent_a_request_to_join_the_group))); + } else { + updates.add(updateDescription(member.getUuid(), requesting -> context.getString(R.string.MessageRecord_s_requested_to_join_via_the_sharable_group_link, requesting))); + } + } + } + + private void describeRequestingMembersApprovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedApproveMember requestingMember : change.getPromoteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.getUuid().equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_approved_your_request_to_join_the_group, editor))); + } else { + updates.add(updateDescription(change.getEditor(), requestingMember.getUuid(), (editor, requesting) -> context.getString(R.string.MessageRecord_s_approved_a_request_to_join_the_group_from_s, editor, requesting))); + } + } + } + + private void describeUnknownEditorRequestingMembersApprovals(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (DecryptedApproveMember requestingMember : change.getPromoteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.getUuid().equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_approved))); + } else { + updates.add(updateDescription(requestingMember.getUuid(), requesting -> context.getString(R.string.MessageRecord_a_request_to_join_the_group_from_s_has_been_approved, requesting))); + } + } + } + + private void describeRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (ByteString requestingMember : change.getDeleteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin))); + } else { + updates.add(updateDescription(change.getEditor(), requestingMember, (editor, requesting) -> context.getString(R.string.MessageRecord_s_denied_a_request_to_join_the_group_from_s, editor, requesting))); + } + } + } + + private void describeUnknownEditorRequestingMembersDeletes(@NonNull DecryptedGroupChange change, @NonNull List updates) { + for (ByteString requestingMember : change.getDeleteRequestingMembersList()) { + boolean requestingMemberIsYou = requestingMember.equals(selfUuidBytes); + + if (requestingMemberIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_your_request_to_join_the_group_has_been_denied_by_an_admin))); + } else { + updates.add(updateDescription(requestingMember, requesting -> context.getString(R.string.MessageRecord_a_request_to_join_the_group_from_s_has_been_denied, requesting))); + } + } + } + interface DescribeMemberStrategy { /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java index b10f901a9..38f9277d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GV2AccessLevelUtil.java @@ -14,6 +14,7 @@ public final class GV2AccessLevelUtil { public static String toString(@NonNull Context context, @NonNull AccessControl.AccessRequired attributeAccess) { switch (attributeAccess) { + case ANY : return context.getString(R.string.GroupManagement_access_level_anyone); case MEMBER : return context.getString(R.string.GroupManagement_access_level_all_members); case ADMINISTRATOR : return context.getString(R.string.GroupManagement_access_level_only_admins); default : return context.getString(R.string.GroupManagement_access_level_unknown); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java index d4f273530..ce9e1ddcb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl.java @@ -8,7 +8,7 @@ import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.GroupInviteLink; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.groups.GroupMasterKey; -import org.thoughtcrime.securesms.util.Base64UrlSafe; +import org.whispersystems.util.Base64UrlSafe; import java.io.IOException; import java.net.MalformedURLException; 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 index a0381f558..1fd039ef1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySet.java @@ -3,9 +3,12 @@ package org.thoughtcrime.securesms.groups.v2; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +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.DecryptedRequestingMember; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.profiles.ProfileKey; import org.thoughtcrime.securesms.logging.Log; @@ -50,6 +53,10 @@ public final class ProfileKeySet { for (DecryptedMember member : change.getModifiedProfileKeysList()) { addMemberKey(member, editor); } + + for (DecryptedRequestingMember member : change.getNewRequestingMembersList()) { + addMemberKey(editor, member.getUuid(), member.getProfileKey()); + } } /** @@ -66,7 +73,14 @@ public final class ProfileKeySet { } private void addMemberKey(@NonNull DecryptedMember member, @Nullable UUID changeSource) { - UUID memberUuid = UuidUtil.fromByteString(member.getUuid()); + addMemberKey(changeSource, member.getUuid(), member.getProfileKey()); + } + + private void addMemberKey(@Nullable UUID changeSource, + @NonNull ByteString memberUuidBytes, + @NonNull ByteString profileKeyBytes) + { + UUID memberUuid = UuidUtil.fromByteString(memberUuidBytes); if (UuidUtil.UNKNOWN_UUID.equals(memberUuid)) { Log.w(TAG, "Seen unknown member UUID"); @@ -75,7 +89,7 @@ public final class ProfileKeySet { ProfileKey profileKey; try { - profileKey = new ProfileKey(member.getProfileKey().toByteArray()); + profileKey = new ProfileKey(profileKeyBytes.toByteArray()); } catch (InvalidInputException e) { Log.w(TAG, "Bad profile key in group"); return; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7995b5f7..794fa12d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -463,6 +463,7 @@ You + Anyone All members Only admins Unknown @@ -914,6 +915,41 @@ %1$s changed who can edit group membership to \"%2$s\". Who can edit group membership has been changed to \"%1$s\". + + You turned on the sharable group link. + You turned on the sharable group link with admin approval. + You turned off the sharable group link. + %1$s turned on the sharable group link. + %1$s turned on the sharable group link with admin approval. + %1$s turned off the sharable group link. + The sharable group link has been turned on. + The sharable group link has been turned on with admin approval. + The sharable group link has been turned off. + + + You reset the sharable group link. + %1$s reset the sharable group link. + The sharable group link has been reset. + + + You joined the group via the sharable group link. + %1$s joined the group via the sharable group link. + + + You sent a request to join the group. + %1$s requested to join via the sharable group link. + + + %1$s approved your request to join the group. + %1$s approved a request to join the group from %2$s. + Your request to join the group has been approved. + A request to join the group from %1$s has been approved. + + + Your request to join the group has been denied by an admin. + %1$s denied a request to join the group from %2$s. + A request to join the group from %1$s has been denied. + Your safety number with %s has changed. 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 0241ff545..2a5fe00b0 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 @@ -143,7 +143,7 @@ public final class GroupsV2UpdateMessageProducerTest { .addMember(you) .build(); - assertThat(describeChange(change), is(singletonList("You joined the group."))); + assertThat(describeChange(change), is(singletonList("You joined the group via the sharable group link."))); } @Test @@ -152,7 +152,7 @@ public final class GroupsV2UpdateMessageProducerTest { .addMember(bob) .build(); - assertThat(describeChange(change), is(singletonList("Bob joined the group."))); + assertThat(describeChange(change), is(singletonList("Bob joined the group via the sharable group link."))); } @Test @@ -209,7 +209,7 @@ public final class GroupsV2UpdateMessageProducerTest { .addMember(you) .build(); - assertThat(describeChange(change), is(Arrays.asList("You joined the group.", "You added Alice."))); + assertThat(describeChange(change), is(Arrays.asList("You joined the group via the sharable group link.", "You added Alice."))); } // Member removals @@ -838,6 +838,257 @@ public final class GroupsV2UpdateMessageProducerTest { assertThat(describeChange(change), is(singletonList("Who can edit group membership has been changed to \"Only admins\"."))); } + // Group link access change + + @Test + public void you_changed_group_link_access_to_any() { + DecryptedGroupChange change = changeBy(you) + .inviteLinkAccess(AccessControl.AccessRequired.ANY) + .build(); + + assertThat(describeChange(change), is(singletonList("You turned on the sharable group link."))); + } + + @Test + public void you_changed_group_link_access_to_administrator_approval() { + DecryptedGroupChange change = changeBy(you) + .inviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + assertThat(describeChange(change), is(singletonList("You turned on the sharable group link with admin approval."))); + } + + @Test + public void you_turned_off_group_link_access() { + DecryptedGroupChange change = changeBy(you) + .inviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE) + .build(); + + assertThat(describeChange(change), is(singletonList("You turned off the sharable group link."))); + } + + @Test + public void member_changed_group_link_access_to_any() { + DecryptedGroupChange change = changeBy(alice) + .inviteLinkAccess(AccessControl.AccessRequired.ANY) + .build(); + + assertThat(describeChange(change), is(singletonList("Alice turned on the sharable group link."))); + } + + @Test + public void member_changed_group_link_access_to_administrator_approval() { + DecryptedGroupChange change = changeBy(bob) + .inviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + assertThat(describeChange(change), is(singletonList("Bob turned on the sharable group link with admin approval."))); + } + + @Test + public void member_turned_off_group_link_access() { + DecryptedGroupChange change = changeBy(alice) + .inviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE) + .build(); + + assertThat(describeChange(change), is(singletonList("Alice turned off the sharable group link."))); + } + + @Test + public void unknown_changed_group_link_access_to_any() { + DecryptedGroupChange change = changeByUnknown() + .inviteLinkAccess(AccessControl.AccessRequired.ANY) + .build(); + + assertThat(describeChange(change), is(singletonList("The sharable group link has been turned on."))); + } + + @Test + public void unknown_changed_group_link_access_to_administrator_approval() { + DecryptedGroupChange change = changeByUnknown() + .inviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + assertThat(describeChange(change), is(singletonList("The sharable group link has been turned on with admin approval."))); + } + + @Test + public void unknown_turned_off_group_link_access() { + DecryptedGroupChange change = changeByUnknown() + .inviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE) + .build(); + + assertThat(describeChange(change), is(singletonList("The sharable group link has been turned off."))); + } + + // Group link reset + + @Test + public void you_reset_group_link() { + DecryptedGroupChange change = changeBy(you) + .resetGroupLink() + .build(); + + assertThat(describeChange(change), is(singletonList("You reset the sharable group link."))); + } + + @Test + public void member_reset_group_link() { + DecryptedGroupChange change = changeBy(alice) + .resetGroupLink() + .build(); + + assertThat(describeChange(change), is(singletonList("Alice reset the sharable group link."))); + } + + @Test + public void unknown_reset_group_link() { + DecryptedGroupChange change = changeByUnknown() + .resetGroupLink() + .build(); + + assertThat(describeChange(change), is(singletonList("The sharable group link has been reset."))); + } + + /** + * When the group link is turned on and reset in the same change, assume this is the first time + * the link password it being set and do not show reset message. + */ + @Test + public void member_changed_group_link_access_to_on_and_reset() { + DecryptedGroupChange change = changeBy(alice) + .inviteLinkAccess(AccessControl.AccessRequired.ANY) + .resetGroupLink() + .build(); + + assertThat(describeChange(change), is(singletonList("Alice turned on the sharable group link."))); + } + + /** + * When the group link is turned on and reset in the same change, assume this is the first time + * the link password it being set and do not show reset message. + */ + @Test + public void you_changed_group_link_access_to_on_and_reset() { + DecryptedGroupChange change = changeBy(you) + .inviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .resetGroupLink() + .build(); + + assertThat(describeChange(change), is(singletonList("You turned on the sharable group link with admin approval."))); + } + + @Test + public void you_changed_group_link_access_to_off_and_reset() { + DecryptedGroupChange change = changeBy(you) + .inviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE) + .resetGroupLink() + .build(); + + assertThat(describeChange(change), is(Arrays.asList("You turned off the sharable group link.", "You reset the sharable group link."))); + } + + // Group link request + + @Test + public void you_requested_to_join_the_group() { + DecryptedGroupChange change = changeBy(you) + .requestJoin() + .build(); + + assertThat(describeChange(change), is(singletonList("You sent a request to join the group."))); + } + + @Test + public void member_requested_to_join_the_group() { + DecryptedGroupChange change = changeBy(bob) + .requestJoin() + .build(); + + assertThat(describeChange(change), is(singletonList("Bob requested to join via the sharable group link."))); + } + + @Test + public void unknown_requested_to_join_the_group() { + DecryptedGroupChange change = changeByUnknown() + .requestJoin(alice) + .build(); + + assertThat(describeChange(change), is(singletonList("Alice requested to join via the sharable group link."))); + } + + @Test + public void member_approved_your_join_request() { + DecryptedGroupChange change = changeBy(bob) + .approveRequest(you) + .build(); + + assertThat(describeChange(change), is(singletonList("Bob approved your request to join the group."))); + } + + @Test + public void member_approved_another_join_request() { + DecryptedGroupChange change = changeBy(alice) + .approveRequest(bob) + .build(); + + assertThat(describeChange(change), is(singletonList("Alice approved a request to join the group from Bob."))); + } + + @Test + public void unknown_approved_your_join_request() { + DecryptedGroupChange change = changeByUnknown() + .approveRequest(you) + .build(); + + assertThat(describeChange(change), is(singletonList("Your request to join the group has been approved."))); + } + + @Test + public void unknown_approved_another_join_request() { + DecryptedGroupChange change = changeByUnknown() + .approveRequest(bob) + .build(); + + assertThat(describeChange(change), is(singletonList("A request to join the group from Bob has been approved."))); + } + + @Test + public void member_denied_another_join_request() { + DecryptedGroupChange change = changeBy(alice) + .denyRequest(bob) + .build(); + + assertThat(describeChange(change), is(singletonList("Alice denied a request to join the group from Bob."))); + } + + @Test + public void member_denied_your_join_request() { + DecryptedGroupChange change = changeBy(alice) + .denyRequest(you) + .build(); + + assertThat(describeChange(change), is(singletonList("Your request to join the group has been denied by an admin."))); + } + + @Test + public void unknown_denied_your_join_request() { + DecryptedGroupChange change = changeByUnknown() + .denyRequest(you) + .build(); + + assertThat(describeChange(change), is(singletonList("Your request to join the group has been denied by an admin."))); + } + + @Test + public void unknown_denied_another_join_request() { + DecryptedGroupChange change = changeByUnknown() + .denyRequest(bob) + .build(); + + assertThat(describeChange(change), is(singletonList("A request to join the group from Bob has been denied."))); + } + // Multiple changes @Test diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ChangeBuilder.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ChangeBuilder.java index 986510eb3..135f1c488 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ChangeBuilder.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ChangeBuilder.java @@ -1,26 +1,32 @@ package org.thoughtcrime.securesms.groups.v2; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; 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.profiles.ProfileKey; +import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.UUID; public final class ChangeBuilder { - private final DecryptedGroupChange.Builder builder; + private final DecryptedGroupChange.Builder builder; + @Nullable private final UUID editor; public static ChangeBuilder changeBy(@NonNull UUID editor) { return new ChangeBuilder(editor); @@ -31,12 +37,14 @@ public final class ChangeBuilder { } ChangeBuilder(@NonNull UUID editor) { - builder = DecryptedGroupChange.newBuilder() - .setEditor(UuidUtil.toByteString(editor)); + this.editor = editor; + this.builder = DecryptedGroupChange.newBuilder() + .setEditor(UuidUtil.toByteString(editor)); } ChangeBuilder() { - builder = DecryptedGroupChange.newBuilder(); + this.editor = null; + this.builder = DecryptedGroupChange.newBuilder(); } public ChangeBuilder addMember(@NonNull UUID newMember) { @@ -139,7 +147,58 @@ public final class ChangeBuilder { return this; } + public ChangeBuilder inviteLinkAccess(@NonNull AccessControl.AccessRequired accessRequired) { + builder.setNewInviteLinkAccess(accessRequired); + return this; + } + + public ChangeBuilder resetGroupLink() { + builder.setNewInviteLinkPassword(ByteString.copyFrom(GroupLinkPassword.createNew().serialize())); + return this; + } + + public ChangeBuilder requestJoin() { + if (editor == null) throw new AssertionError(); + return requestJoin(editor, newProfileKey()); + } + + public ChangeBuilder requestJoin(@NonNull UUID requester) { + return requestJoin(requester, newProfileKey()); + } + + public ChangeBuilder requestJoin(@NonNull ProfileKey profileKey) { + if (editor == null) throw new AssertionError(); + return requestJoin(editor, profileKey); + } + + public ChangeBuilder requestJoin(@NonNull UUID requester, @NonNull ProfileKey profileKey) { + builder.addNewRequestingMembers(DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(requester)) + .setProfileKey(ByteString.copyFrom(profileKey.serialize()))); + return this; + } + + public ChangeBuilder approveRequest(@NonNull UUID approvedMember) { + builder.addPromoteRequestingMembers(DecryptedApproveMember.newBuilder() + .setRole(Member.Role.DEFAULT) + .setUuid(UuidUtil.toByteString(approvedMember))); + return this; + } + + public ChangeBuilder denyRequest(@NonNull UUID approvedMember) { + builder.addDeleteRequestingMembers(UuidUtil.toByteString(approvedMember)); + return this; + } + public DecryptedGroupChange build() { return builder.build(); } + + private static ProfileKey newProfileKey() { + try { + return new ProfileKey(Util.getSecretBytes(32)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl_InvalidGroupLinkException_Test.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl_InvalidGroupLinkException_Test.java index 261088f1d..225547607 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl_InvalidGroupLinkException_Test.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/GroupInviteLinkUrl_InvalidGroupLinkException_Test.java @@ -9,7 +9,7 @@ import org.junit.Test; import org.signal.storageservice.protos.groups.GroupInviteLink; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.groups.GroupMasterKey; -import org.thoughtcrime.securesms.util.Base64UrlSafe; +import org.whispersystems.util.Base64UrlSafe; import org.thoughtcrime.securesms.util.Util; import java.io.IOException; diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java index 3481b999e..35e517a6a 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/ProfileKeySetTest.java @@ -6,13 +6,11 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.testutil.LogRecorder; -import java.util.Collection; import java.util.UUID; import edu.emory.mathcs.backport.java.util.Collections; import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.thoughtcrime.securesms.groups.v2.ChangeBuilder.changeBy; @@ -179,4 +177,29 @@ public final class ProfileKeySetTest { assertTrue(profileKeySet.getAuthoritativeProfileKeys().isEmpty()); assertThat(logRecorder.getWarnings(), hasMessages("Bad profile key in group")); } + + @Test + public void new_requesting_member_if_editor_is_authoritative() { + UUID editor = UUID.randomUUID(); + ProfileKey profileKey = ProfileKeyUtil.createNew(); + ProfileKeySet profileKeySet = new ProfileKeySet(); + + profileKeySet.addKeysFromGroupChange(changeBy(editor).requestJoin(profileKey).build()); + + assertThat(profileKeySet.getAuthoritativeProfileKeys(), is(Collections.singletonMap(editor, profileKey))); + assertTrue(profileKeySet.getProfileKeys().isEmpty()); + } + + @Test + public void new_requesting_member_if_not_editor_is_not_authoritative() { + UUID editor = UUID.randomUUID(); + UUID requesting = UUID.randomUUID(); + ProfileKey profileKey = ProfileKeyUtil.createNew(); + ProfileKeySet profileKeySet = new ProfileKeySet(); + + profileKeySet.addKeysFromGroupChange(changeBy(editor).requestJoin(requesting, profileKey).build()); + + assertTrue(profileKeySet.getAuthoritativeProfileKeys().isEmpty()); + assertThat(profileKeySet.getProfileKeys(), is(Collections.singletonMap(requesting, profileKey))); + } } diff --git a/libsignal/service/build.gradle b/libsignal/service/build.gradle index 70b0bf013..ffd3d97bc 100644 --- a/libsignal/service/build.gradle +++ b/libsignal/service/build.gradle @@ -39,7 +39,7 @@ dependencies { api 'org.signal:zkgroup-java:0.7.0' testImplementation 'junit:junit:4.12' - testImplementation 'org.assertj:assertj-core:1.7.1' + testImplementation 'org.assertj:assertj-core:3.11.1' testImplementation 'org.conscrypt:conscrypt-openjdk-uber:2.0.0' } 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 0ebc1cf01..be1f07f46 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 @@ -4,12 +4,14 @@ import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; import org.whispersystems.libsignal.logging.Log; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.util.UuidUtil; @@ -26,8 +28,6 @@ public final class DecryptedGroupUtil { private static final String TAG = DecryptedGroupUtil.class.getSimpleName(); - static final int MAX_CHANGE_FIELD = 14; - public static ArrayList toUuidList(Collection membersList) { ArrayList uuidList = new ArrayList<>(membersList.size()); @@ -256,6 +256,16 @@ public final class DecryptedGroupUtil { applyModifyMembersAccessControlAction(builder, change); + applyModifyAddFromInviteLinkAccessControlAction(builder, change); + + applyAddRequestingMembers(builder, change.getNewRequestingMembersList()); + + applyDeleteRequestingMembers(builder, change.getDeleteRequestingMembersList()); + + applyPromoteRequestingMemberActions(builder, change.getPromoteRequestingMembersList()); + + applyInviteLinkPassword(builder, change); + return builder.build(); } @@ -286,11 +296,12 @@ public final class DecryptedGroupUtil { throw new NotAbleToApplyGroupV2ChangeException(); } - if (modifyMemberRole.getRole() != Member.Role.ADMINISTRATOR && modifyMemberRole.getRole() != Member.Role.DEFAULT) { - throw new NotAbleToApplyGroupV2ChangeException(); - } + Member.Role role = modifyMemberRole.getRole(); - builder.setMembers(index, DecryptedMember.newBuilder(builder.getMembers(index)).setRole(modifyMemberRole.getRole()).build()); + ensureKnownRole(role); + + builder.setMembers(index, DecryptedMember.newBuilder(builder.getMembers(index)) + .setRole(role)); } } @@ -366,18 +377,74 @@ public final class DecryptedGroupUtil { } protected static void applyModifyAttributesAccessControlAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) { - if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) { + AccessControl.AccessRequired newAccessLevel = change.getNewAttributeAccess(); + + if (newAccessLevel != AccessControl.AccessRequired.UNKNOWN) { builder.setAccessControl(AccessControl.newBuilder(builder.getAccessControl()) - .setAttributesValue(change.getNewAttributeAccessValue()) - .build()); + .setAttributesValue(change.getNewAttributeAccessValue())); } } protected static void applyModifyMembersAccessControlAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) { - if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) { + AccessControl.AccessRequired newAccessLevel = change.getNewMemberAccess(); + + if (newAccessLevel != AccessControl.AccessRequired.UNKNOWN) { builder.setAccessControl(AccessControl.newBuilder(builder.getAccessControl()) - .setMembersValue(change.getNewMemberAccessValue()) - .build()); + .setMembersValue(change.getNewMemberAccessValue())); + } + } + + protected static void applyModifyAddFromInviteLinkAccessControlAction(DecryptedGroup.Builder builder, DecryptedGroupChange change) { + AccessControl.AccessRequired newAccessLevel = change.getNewInviteLinkAccess(); + + if (newAccessLevel != AccessControl.AccessRequired.UNKNOWN) { + builder.setAccessControl(AccessControl.newBuilder(builder.getAccessControl()) + .setAddFromInviteLink(newAccessLevel)); + } + } + + private static void applyAddRequestingMembers(DecryptedGroup.Builder builder, List newRequestingMembers) { + builder.addAllRequestingMembers(newRequestingMembers); + } + + private static void applyDeleteRequestingMembers(DecryptedGroup.Builder builder, List deleteRequestingMembersList) { + for (ByteString removedMember : deleteRequestingMembersList) { + int index = indexOfUuidInRequestingList(builder.getRequestingMembersList(), removedMember); + + if (index == -1) { + Log.w(TAG, "Deleted member on change not found in group"); + continue; + } + + builder.removeRequestingMembers(index); + } + } + + private static void applyPromoteRequestingMemberActions(DecryptedGroup.Builder builder, List promoteRequestingMembers) throws NotAbleToApplyGroupV2ChangeException { + for (DecryptedApproveMember approvedMember : promoteRequestingMembers) { + int index = indexOfUuidInRequestingList(builder.getRequestingMembersList(), approvedMember.getUuid()); + + if (index == -1) { + Log.w(TAG, "Deleted member on change not found in group"); + continue; + } + + DecryptedRequestingMember requestingMember = builder.getRequestingMembers(index); + Member.Role role = approvedMember.getRole(); + + ensureKnownRole(role); + + builder.removeRequestingMembers(index) + .addMembers(DecryptedMember.newBuilder() + .setUuid(approvedMember.getUuid()) + .setProfileKey(requestingMember.getProfileKey()) + .setRole(role)); + } + } + + private static void applyInviteLinkPassword(DecryptedGroup.Builder builder, DecryptedGroupChange change) { + if (!change.getNewInviteLinkPassword().isEmpty()) { + builder.setInviteLinkPassword(change.getNewInviteLinkPassword()); } } @@ -418,9 +485,22 @@ public final class DecryptedGroupUtil { } } + private static void ensureKnownRole(Member.Role role) throws NotAbleToApplyGroupV2ChangeException { + if (role != Member.Role.ADMINISTRATOR && role != Member.Role.DEFAULT) { + throw new NotAbleToApplyGroupV2ChangeException(); + } + } + 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; + if (uuid.equals(memberList.get(i).getUuid())) return i; + } + return -1; + } + + private static int indexOfUuidInRequestingList(List memberList, ByteString uuid) { + for (int i = 0; i < memberList.size(); i++) { + if (uuid.equals(memberList.get(i).getUuid())) return i; } return -1; } @@ -437,17 +517,22 @@ public final class DecryptedGroupUtil { } public static boolean changeIsEmptyExceptForProfileKeyChanges(DecryptedGroupChange change) { - return change.getNewMembersCount() == 0 && // field 3 - change.getDeleteMembersCount() == 0 && // field 4 - change.getModifyMemberRolesCount() == 0 && // field 5 - change.getNewPendingMembersCount() == 0 && // field 7 - change.getDeletePendingMembersCount() == 0 && // field 8 - change.getPromotePendingMembersCount() == 0 && // field 9 - !change.hasNewTitle() && // field 10 - !change.hasNewAvatar() && // field 11 - !change.hasNewTimer() && // field 12 - isSet(change.getNewAttributeAccess()) && // field 13 - isSet(change.getNewMemberAccess()); // field 14 + return change.getNewMembersCount() == 0 && // field 3 + change.getDeleteMembersCount() == 0 && // field 4 + change.getModifyMemberRolesCount() == 0 && // field 5 + change.getNewPendingMembersCount() == 0 && // field 7 + change.getDeletePendingMembersCount() == 0 && // field 8 + change.getPromotePendingMembersCount() == 0 && // field 9 + !change.hasNewTitle() && // field 10 + !change.hasNewAvatar() && // field 11 + !change.hasNewTimer() && // field 12 + isSet(change.getNewAttributeAccess()) && // field 13 + isSet(change.getNewMemberAccess()) && // field 14 + isSet(change.getNewInviteLinkAccess()) && // field 15 + change.getNewRequestingMembersCount() == 0 && // field 16 + change.getDeleteRequestingMembersCount() == 0 && // field 17 + change.getPromoteRequestingMembersCount() == 0 && // field 18 + change.getNewInviteLinkPassword().size() == 0; // field 19 } static boolean isSet(AccessControl.AccessRequired newAttributeAccess) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java index 065a151f6..166b6bd31 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java @@ -2,17 +2,19 @@ package org.whispersystems.signalservice.api.groupsv2; import com.google.protobuf.ByteString; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; import org.signal.storageservice.protos.groups.local.DecryptedString; import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -51,16 +53,24 @@ public final class GroupChangeReconstruct { Set pendingMembersListA = pendingMembersToSetOfUuids(fromState.getPendingMembersList()); Set pendingMembersListB = pendingMembersToSetOfUuids(toState.getPendingMembersList()); + + Set requestingMembersListA = requestingMembersToSetOfUuids(fromState.getRequestingMembersList()); + Set requestingMembersListB = requestingMembersToSetOfUuids(toState.getRequestingMembersList()); - Set removedPendingMemberUuids = subtract(pendingMembersListA, pendingMembersListB); - Set newPendingMemberUuids = subtract(pendingMembersListB, pendingMembersListA); - Set removedMemberUuids = subtract(fromStateMemberUuids, toStateMemberUuids); - Set newMemberUuids = subtract(toStateMemberUuids, fromStateMemberUuids); + Set removedPendingMemberUuids = subtract(pendingMembersListA, pendingMembersListB); + Set removedRequestingMemberUuids = subtract(requestingMembersListA, requestingMembersListB); + Set newPendingMemberUuids = subtract(pendingMembersListB, pendingMembersListA); + Set newRequestingMemberUuids = subtract(requestingMembersListB, requestingMembersListA); + Set removedMemberUuids = subtract(fromStateMemberUuids, toStateMemberUuids); + Set newMemberUuids = subtract(toStateMemberUuids, fromStateMemberUuids); - Set addedByInvitationUuids = intersect(newMemberUuids, removedPendingMemberUuids); - Set addedMembersByInvitation = intersectByUUID(toState.getMembersList(), addedByInvitationUuids); - Set addedMembers = intersectByUUID(toState.getMembersList(), subtract(newMemberUuids, addedByInvitationUuids)); - Set uninvitedMembers = intersectPendingByUUID(fromState.getPendingMembersList(), subtract(removedPendingMemberUuids, addedByInvitationUuids)); + Set addedByInvitationUuids = intersect(newMemberUuids, removedPendingMemberUuids); + Set addedByRequestApprovalUuids = intersect(newMemberUuids, removedRequestingMemberUuids); + Set addedMembersByInvitation = intersectByUUID(toState.getMembersList(), addedByInvitationUuids); + Set addedMembersByRequestApproval = intersectByUUID(toState.getMembersList(), addedByRequestApprovalUuids); + Set addedMembers = intersectByUUID(toState.getMembersList(), subtract(newMemberUuids, addedByInvitationUuids, addedByRequestApprovalUuids)); + Set uninvitedMembers = intersectPendingByUUID(fromState.getPendingMembersList(), subtract(removedPendingMemberUuids, addedByInvitationUuids)); + Set rejectedRequestMembers = intersectRequestingByUUID(fromState.getRequestingMembersList(), subtract(removedRequestingMemberUuids, addedByRequestApprovalUuids)); for (DecryptedMember member : intersectByUUID(fromState.getMembersList(), removedMemberUuids)) { builder.addDeleteMembers(member.getUuid()); @@ -101,11 +111,33 @@ public final class GroupChangeReconstruct { } } + if (!fromState.getAccessControl().getAddFromInviteLink().equals(toState.getAccessControl().getAddFromInviteLink())) { + builder.setNewInviteLinkAccess(toState.getAccessControl().getAddFromInviteLink()); + } + + for (DecryptedRequestingMember requestingMember : intersectRequestingByUUID(toState.getRequestingMembersList(), newRequestingMemberUuids)) { + builder.addNewRequestingMembers(requestingMember); + } + + for (DecryptedRequestingMember requestingMember : rejectedRequestMembers) { + builder.addDeleteRequestingMembers(requestingMember.getUuid()); + } + + for (DecryptedMember member : addedMembersByRequestApproval) { + builder.addPromoteRequestingMembers(DecryptedApproveMember.newBuilder() + .setUuid(member.getUuid()) + .setRole(member.getRole())); + } + + if (!fromState.getInviteLinkPassword().equals(toState.getInviteLinkPassword())) { + builder.setNewInviteLinkPassword(toState.getInviteLinkPassword()); + } + return builder.build(); } private static Map uuidMap(List membersList) { - HashMap map = new HashMap<>(membersList.size()); + Map map = new LinkedHashMap<>(membersList.size()); for (DecryptedMember member : membersList) { map.put(member.getUuid(), member); } @@ -113,7 +145,7 @@ public final class GroupChangeReconstruct { } private static Set intersectByUUID(Collection members, Set uuids) { - Set result = new HashSet<>(members.size()); + Set result = new LinkedHashSet<>(members.size()); for (DecryptedMember member : members) { if (uuids.contains(member.getUuid())) result.add(member); @@ -122,24 +154,41 @@ public final class GroupChangeReconstruct { } private static Set intersectPendingByUUID(Collection members, Set uuids) { - Set result = new HashSet<>(members.size()); + Set result = new LinkedHashSet<>(members.size()); for (DecryptedPendingMember member : members) { if (uuids.contains(member.getUuid())) result.add(member); } return result; } + + private static Set intersectRequestingByUUID(Collection members, Set uuids) { + Set result = new LinkedHashSet<>(members.size()); + for (DecryptedRequestingMember member : members) { + if (uuids.contains(member.getUuid())) + result.add(member); + } + return result; + } private static Set pendingMembersToSetOfUuids(Collection pendingMembers) { - HashSet uuids = new HashSet<>(pendingMembers.size()); + Set uuids = new LinkedHashSet<>(pendingMembers.size()); for (DecryptedPendingMember pendingMember : pendingMembers) { uuids.add(pendingMember.getUuid()); } return uuids; } + private static Set requestingMembersToSetOfUuids(Collection requestingMembers) { + Set uuids = new LinkedHashSet<>(requestingMembers.size()); + for (DecryptedRequestingMember requestingMember : requestingMembers) { + uuids.add(requestingMember.getUuid()); + } + return uuids; + } + private static Set membersToSetOfUuids(Collection members) { - HashSet uuids = new HashSet<>(members.size()); + Set uuids = new LinkedHashSet<>(members.size()); for (DecryptedMember member : members) { uuids.add(member.getUuid()); } @@ -147,13 +196,20 @@ public final class GroupChangeReconstruct { } private static Set subtract(Collection a, Collection b) { - Set result = new HashSet<>(a); + Set result = new LinkedHashSet<>(a); result.removeAll(b); return result; } + private static Set subtract(Collection a, Collection b, Collection c) { + Set result = new LinkedHashSet<>(a); + result.removeAll(b); + result.removeAll(c); + return result; + } + private static Set intersect(Collection a, Collection b) { - Set result = new HashSet<>(a); + Set result = new LinkedHashSet<>(a); result.retainAll(b); return result; } 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 8059c489c..4adca5b5c 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 @@ -3,12 +3,14 @@ package org.whispersystems.signalservice.api.groupsv2; import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; import java.util.HashMap; import java.util.List; @@ -18,27 +20,27 @@ public final class GroupChangeUtil { private GroupChangeUtil() { } - /** - * The maximum field we know about here. - */ - static final int CHANGE_ACTION_MAX_FIELD = 14; - /** * True iff there are no change actions. */ public static boolean changeIsEmpty(GroupChange.Actions change) { - return change.getAddMembersCount() == 0 && // field 3 - change.getDeleteMembersCount() == 0 && // field 4 - change.getModifyMemberRolesCount() == 0 && // field 5 - change.getModifyMemberProfileKeysCount() == 0 && // field 6 - change.getAddPendingMembersCount() == 0 && // field 7 - change.getDeletePendingMembersCount() == 0 && // field 8 - change.getPromotePendingMembersCount() == 0 && // field 9 - !change.hasModifyTitle() && // field 10 - !change.hasModifyAvatar() && // field 11 - !change.hasModifyDisappearingMessagesTimer() && // field 12 - !change.hasModifyAttributesAccess() && // field 13 - !change.hasModifyMemberAccess(); // field 14 + return change.getAddMembersCount() == 0 && // field 3 + change.getDeleteMembersCount() == 0 && // field 4 + change.getModifyMemberRolesCount() == 0 && // field 5 + change.getModifyMemberProfileKeysCount() == 0 && // field 6 + change.getAddPendingMembersCount() == 0 && // field 7 + change.getDeletePendingMembersCount() == 0 && // field 8 + change.getPromotePendingMembersCount() == 0 && // field 9 + !change.hasModifyTitle() && // field 10 + !change.hasModifyAvatar() && // field 11 + !change.hasModifyDisappearingMessagesTimer() && // field 12 + !change.hasModifyAttributesAccess() && // field 13 + !change.hasModifyMemberAccess() && // field 14 + !change.hasModifyAddFromInviteLinkAccess() && // field 15 + change.getAddRequestingMembersCount() == 0 && // field 16 + change.getDeleteRequestingMembersCount() == 0 && // field 17 + change.getPromoteRequestingMembersCount() == 0 && // field 18 + !change.hasModifyInviteLinkPassword(); // field 19 } /** @@ -62,9 +64,10 @@ public final class GroupChangeUtil { DecryptedGroupChange conflictingChange, GroupChange.Actions encryptedChange) { - GroupChange.Actions.Builder result = GroupChange.Actions.newBuilder(encryptedChange); - HashMap fullMembersByUuid = new HashMap<>(groupState.getMembersCount()); - HashMap pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount()); + GroupChange.Actions.Builder result = GroupChange.Actions.newBuilder(encryptedChange); + HashMap fullMembersByUuid = new HashMap<>(groupState.getMembersCount()); + HashMap pendingMembersByUuid = new HashMap<>(groupState.getPendingMembersCount()); + HashMap requestingMembersByUuid = new HashMap<>(groupState.getMembersCount()); for (DecryptedMember member : groupState.getMembersList()) { fullMembersByUuid.put(member.getUuid(), member); @@ -74,6 +77,10 @@ public final class GroupChangeUtil { pendingMembersByUuid.put(member.getUuid(), member); } + for (DecryptedRequestingMember member : groupState.getRequestingMembersList()) { + requestingMembersByUuid.put(member.getUuid(), member); + } + resolveField3AddMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid); resolveField4DeleteMembers (conflictingChange, result, fullMembersByUuid); resolveField5ModifyMemberRoles (conflictingChange, result, fullMembersByUuid); @@ -86,6 +93,10 @@ public final class GroupChangeUtil { resolveField12modifyDisappearingMessagesTimer(groupState, conflictingChange, result); resolveField13modifyAttributesAccess (groupState, conflictingChange, result); resolveField14modifyAttributesAccess (groupState, conflictingChange, result); + resolveField15modifyAddFromInviteLinkAccess (groupState, conflictingChange, result); + resolveField16AddRequestingMembers (conflictingChange, result, fullMembersByUuid, pendingMembersByUuid); + resolveField17DeleteMembers (conflictingChange, result, requestingMembersByUuid); + resolveField18PromoteRequestingMembers (conflictingChange, result, requestingMembersByUuid); return result; } @@ -209,4 +220,56 @@ public final class GroupChangeUtil { result.clearModifyMemberAccess(); } } + + private static void resolveField15modifyAddFromInviteLinkAccess(DecryptedGroup groupState, DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result) { + if (conflictingChange.getNewInviteLinkAccess() == groupState.getAccessControl().getAddFromInviteLink()) { + result.clearModifyAddFromInviteLinkAccess(); + } + } + + private static void resolveField16AddRequestingMembers(DecryptedGroupChange conflictingChange, GroupChange.Actions.Builder result, HashMap fullMembersByUuid, HashMap pendingMembersByUuid) { + List newMembersList = conflictingChange.getNewRequestingMembersList(); + + for (int i = newMembersList.size() - 1; i >= 0; i--) { + DecryptedRequestingMember member = newMembersList.get(i); + + if (fullMembersByUuid.containsKey(member.getUuid())) { + result.removeAddRequestingMembers(i); + } else if (pendingMembersByUuid.containsKey(member.getUuid())) { + GroupChange.Actions.AddRequestingMemberAction addMemberAction = result.getAddRequestingMembersList().get(i); + result.removeAddRequestingMembers(i); + result.addPromotePendingMembers(0, GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(addMemberAction.getAdded().getPresentation())); + } + } + } + + private static void resolveField17DeleteMembers(DecryptedGroupChange conflictingChange, + GroupChange.Actions.Builder result, + HashMap requestingMembers) + { + List deletedMembersList = conflictingChange.getDeleteRequestingMembersList(); + + for (int i = deletedMembersList.size() - 1; i >= 0; i--) { + ByteString member = deletedMembersList.get(i); + + if (!requestingMembers.containsKey(member)) { + result.removeDeleteRequestingMembers(i); + } + } + } + + private static void resolveField18PromoteRequestingMembers(DecryptedGroupChange conflictingChange, + GroupChange.Actions.Builder result, + HashMap requestingMembersByUuid) + { + List promoteRequestingMembersList = conflictingChange.getPromoteRequestingMembersList(); + + for (int i = promoteRequestingMembersList.size() - 1; i >= 0; i--) { + DecryptedApproveMember member = promoteRequestingMembersList.get(i); + + if (!requestingMembersByUuid.containsKey(member.getUuid())) { + result.removePromoteRequestingMembers(i); + } + } + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupLinkNotActiveException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupLinkNotActiveException.java new file mode 100644 index 000000000..d4201e2b8 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupLinkNotActiveException.java @@ -0,0 +1,12 @@ +package org.whispersystems.signalservice.api.groupsv2; + +/** + * Thrown when a group link: + * - has an out of date password, or; + * - is currently not shared, or; + * - the master key does not match a group on the server + */ +public final class GroupLinkNotActiveException extends Exception { + GroupLinkNotActiveException() { + } +} 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 4e52e2979..7c3a12775 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 @@ -7,8 +7,10 @@ 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.GroupJoinInfo; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.auth.AuthCredential; @@ -19,6 +21,7 @@ import org.signal.zkgroup.groups.ClientZkGroupCipher; import org.signal.zkgroup.groups.GroupSecretParams; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.internal.push.PushServiceSocket; +import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException; import java.io.IOException; import java.security.SecureRandom; @@ -110,6 +113,21 @@ public final class GroupsV2Api { return result; } + public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams, + byte[] password, + GroupsV2AuthorizationString authorization) + throws IOException, GroupLinkNotActiveException + { + try { + GroupJoinInfo joinInfo = socket.getGroupJoinInfo(password, authorization); + GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams); + + return groupOperations.decryptGroupJoinInfo(joinInfo); + } catch (ForbiddenException e) { + throw new GroupLinkNotActiveException(); + } + } + public String uploadAvatar(byte[] avatar, GroupSecretParams groupSecretParams, GroupsV2AuthorizationString authorization) 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 cfdd660c3..bce30925f 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 @@ -7,14 +7,19 @@ import org.signal.storageservice.protos.groups.AccessControl; 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.GroupJoinInfo; import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.protos.groups.PendingMember; +import org.signal.storageservice.protos.groups.RequestingMember; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; 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.DecryptedRequestingMember; import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.InvalidInputException; @@ -54,7 +59,7 @@ public final class GroupsV2Operations { public static final UUID UNKNOWN_UUID = UuidUtil.UNKNOWN_UUID; /** Highest change epoch this class knows now to decrypt */ - public static final int HIGHEST_KNOWN_EPOCH = 0; + public static final int HIGHEST_KNOWN_EPOCH = 1; private final ServerPublicParams serverPublicParams; private final ClientZkProfileOperations clientZkProfileOperations; @@ -137,8 +142,9 @@ public final class GroupsV2Operations { } public GroupChange.Actions.Builder createModifyGroupTitle(final String title) { - return GroupChange.Actions.newBuilder().setModifyTitle(GroupChange.Actions.ModifyTitleAction.newBuilder() - .setTitle(encryptTitle(title))); + return GroupChange.Actions.newBuilder().setModifyTitle(GroupChange.Actions.ModifyTitleAction + .newBuilder() + .setTitle(encryptTitle(title))); } public GroupChange.Actions.Builder createModifyGroupMembershipChange(Set membersToAdd, UUID selfUuid) { @@ -151,24 +157,75 @@ public final class GroupsV2Operations { ProfileKeyCredential profileKeyCredential = credential.getProfileKeyCredential().orNull(); if (profileKeyCredential != null) { - actions.addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder() - .setAdded(groupOperations.member(profileKeyCredential, newMemberRole))); + actions.addAddMembers(GroupChange.Actions.AddMemberAction + .newBuilder() + .setAdded(groupOperations.member(profileKeyCredential, newMemberRole))); } else { - actions.addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder() - .setAdded(groupOperations.invitee(credential.getUuid(), newMemberRole) - .setAddedByUserId(encryptUuid(selfUuid)))); + actions.addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction + .newBuilder() + .setAdded(groupOperations.invitee(credential.getUuid(), newMemberRole) + .setAddedByUserId(encryptUuid(selfUuid)))); } } return actions; } + public GroupChange.Actions.Builder createGroupJoinRequest(ProfileKeyCredential profileKeyCredential) { + GroupOperations groupOperations = forGroup(groupSecretParams); + GroupChange.Actions.Builder actions = GroupChange.Actions.newBuilder(); + + actions.addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction + .newBuilder() + .setAdded(groupOperations.requestingMember(profileKeyCredential))); + + return actions; + } + + public GroupChange.Actions.Builder createGroupJoinDirect(ProfileKeyCredential profileKeyCredential) { + GroupOperations groupOperations = forGroup(groupSecretParams); + GroupChange.Actions.Builder actions = GroupChange.Actions.newBuilder(); + + actions.addAddMembers(GroupChange.Actions.AddMemberAction + .newBuilder() + .setAdded(groupOperations.member(profileKeyCredential, Member.Role.DEFAULT)) + .setJoinFromInviteLink(true)); + + return actions; + } + + public GroupChange.Actions.Builder createRefuseGroupJoinRequest(Set requestsToRemove) { + GroupChange.Actions.Builder actions = GroupChange.Actions.newBuilder(); + + for (UUID uuid : requestsToRemove) { + actions.addDeleteRequestingMembers(GroupChange.Actions.DeleteRequestingMemberAction + .newBuilder() + .setDeletedUserId(encryptUuid(uuid))); + } + + return actions; + } + + public GroupChange.Actions.Builder createApproveGroupJoinRequest(Set requestsToApprove) { + GroupChange.Actions.Builder actions = GroupChange.Actions.newBuilder(); + + for (UUID uuid : requestsToApprove) { + actions.addPromoteRequestingMembers(GroupChange.Actions.PromoteRequestingMemberAction + .newBuilder() + .setRole(Member.Role.DEFAULT) + .setUserId(encryptUuid(uuid))); + } + + return actions; + } + public GroupChange.Actions.Builder createRemoveMembersChange(final Set membersToRemove) { GroupChange.Actions.Builder actions = GroupChange.Actions.newBuilder(); for (UUID remove: membersToRemove) { - actions.addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder() - .setDeletedUserId(encryptUuid(remove))); + actions.addDeleteMembers(GroupChange.Actions.DeleteMemberAction + .newBuilder() + .setDeletedUserId(encryptUuid(remove))); } return actions; @@ -178,9 +235,10 @@ public final class GroupsV2Operations { GroupChange.Actions.Builder actions = createRemoveMembersChange(Collections.singleton(self)); for (UUID member : membersToMakeAdmin) { - actions.addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder() - .setUserId(encryptUuid(member)) - .setRole(Member.Role.ADMINISTRATOR)); + actions.addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction + .newBuilder() + .setUserId(encryptUuid(member)) + .setRole(Member.Role.ADMINISTRATOR)); } return actions; @@ -189,7 +247,8 @@ public final class GroupsV2Operations { public GroupChange.Actions.Builder createModifyGroupTimerChange(int timerDurationSeconds) { return GroupChange.Actions .newBuilder() - .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder() + .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction + .newBuilder() .setTimer(encryptTimer(timerDurationSeconds))); } @@ -198,7 +257,8 @@ public final class GroupsV2Operations { return GroupChange.Actions .newBuilder() - .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder() + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction + .newBuilder() .setPresentation(ByteString.copyFrom(presentation.serialize()))); } @@ -207,8 +267,9 @@ public final class GroupsV2Operations { return GroupChange.Actions .newBuilder() - .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder() - .setPresentation(ByteString.copyFrom(presentation.serialize()))); + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction + .newBuilder() + .setPresentation(ByteString.copyFrom(presentation.serialize()))); } public GroupChange.Actions.Builder createRemoveInvitationChange(final Set uuidCipherTextsFromInvitesToRemove) { @@ -216,37 +277,90 @@ public final class GroupsV2Operations { .newBuilder(); for (UuidCiphertext uuidCipherText: uuidCipherTextsFromInvitesToRemove) { - builder.addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder() - .setDeletedUserId(ByteString.copyFrom(uuidCipherText.serialize()))); + builder.addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction + .newBuilder() + .setDeletedUserId(ByteString.copyFrom(uuidCipherText.serialize()))); } return builder; } + public GroupChange.Actions.Builder createModifyGroupLinkPasswordChange(byte[] groupLinkPassword) { + return GroupChange.Actions + .newBuilder() + .setModifyInviteLinkPassword(GroupChange.Actions.ModifyInviteLinkPasswordAction + .newBuilder() + .setInviteLinkPassword(ByteString.copyFrom(groupLinkPassword))); + } + + public GroupChange.Actions.Builder createModifyGroupLinkPasswordAndRightsChange(byte[] groupLinkPassword, AccessControl.AccessRequired newRights) { + GroupChange.Actions.Builder change = createModifyGroupLinkPasswordChange(groupLinkPassword); + + return change.setModifyAddFromInviteLinkAccess(GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction + .newBuilder() + .setAddFromInviteLinkAccess(newRights)); + } + + public GroupChange.Actions.Builder createChangeJoinByLinkRights(AccessControl.AccessRequired newRights) { + return GroupChange.Actions + .newBuilder() + .setModifyAddFromInviteLinkAccess(GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction + .newBuilder() + .setAddFromInviteLinkAccess(newRights)); + } + + public GroupChange.Actions.Builder createChangeMembershipRights(AccessControl.AccessRequired newRights) { + return GroupChange.Actions + .newBuilder() + .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction + .newBuilder() + .setMembersAccess(newRights)); + } + + public GroupChange.Actions.Builder createChangeAttributesRights(AccessControl.AccessRequired newRights) { + return GroupChange.Actions + .newBuilder() + .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction + .newBuilder() + .setAttributesAccess(newRights)); + } + private Member.Builder member(ProfileKeyCredential credential, Member.Role role) { ProfileKeyCredentialPresentation presentation = clientZkProfileOperations.createProfileKeyCredentialPresentation(new SecureRandom(), groupSecretParams, credential); - return Member.newBuilder().setRole(role) - .setPresentation(ByteString.copyFrom(presentation.serialize())); + return Member.newBuilder() + .setRole(role) + .setPresentation(ByteString.copyFrom(presentation.serialize())); + } + + private RequestingMember.Builder requestingMember(ProfileKeyCredential credential) { + ProfileKeyCredentialPresentation presentation = clientZkProfileOperations.createProfileKeyCredentialPresentation(new SecureRandom(), groupSecretParams, credential); + + return RequestingMember.newBuilder() + .setPresentation(ByteString.copyFrom(presentation.serialize())); } public PendingMember.Builder invitee(UUID uuid, Member.Role role) { UuidCiphertext uuidCiphertext = clientZkGroupCipher.encryptUuid(uuid); - Member member = Member.newBuilder().setRole(role) - .setUserId(ByteString.copyFrom(uuidCiphertext.serialize())) - .build(); + Member member = Member.newBuilder() + .setRole(role) + .setUserId(ByteString.copyFrom(uuidCiphertext.serialize())) + .build(); - return PendingMember.newBuilder().setMember(member); + return PendingMember.newBuilder() + .setMember(member); } public DecryptedGroup decryptGroup(Group group) throws VerificationFailedException, InvalidGroupStateException { - List membersList = group.getMembersList(); - List pendingMembersList = group.getPendingMembersList(); - List decryptedMembers = new ArrayList<>(membersList.size()); - List decryptedPendingMembers = new ArrayList<>(pendingMembersList.size()); + List membersList = group.getMembersList(); + List pendingMembersList = group.getPendingMembersList(); + List requestingMembersList = group.getRequestingMembersList(); + List decryptedMembers = new ArrayList<>(membersList.size()); + List decryptedPendingMembers = new ArrayList<>(pendingMembersList.size()); + List decryptedRequestingMembers = new ArrayList<>(requestingMembersList.size()); for (Member member : membersList) { try { @@ -260,6 +374,10 @@ public final class GroupsV2Operations { decryptedPendingMembers.add(decryptMember(member)); } + for (RequestingMember member : requestingMembersList) { + decryptedRequestingMembers.add(decryptRequestingMember(member)); + } + return DecryptedGroup.newBuilder() .setTitle(decryptTitle(group.getTitle())) .setAvatar(group.getAvatar()) @@ -267,7 +385,9 @@ public final class GroupsV2Operations { .setRevision(group.getRevision()) .addAllMembers(decryptedMembers) .addAllPendingMembers(decryptedPendingMembers) + .addAllRequestingMembers(decryptedRequestingMembers) .setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(decryptDisappearingMessagesTimer(group.getDisappearingMessagesTimer()))) + .setInviteLinkPassword(group.getInviteLinkPassword()) .build(); } @@ -419,9 +539,44 @@ public final class GroupsV2Operations { builder.setNewMemberAccess(actions.getModifyMemberAccess().getMembersAccess()); } + // Field 15 + if (actions.hasModifyAddFromInviteLinkAccess()) { + builder.setNewInviteLinkAccess(actions.getModifyAddFromInviteLinkAccess().getAddFromInviteLinkAccess()); + } + + // Field 16 + for (GroupChange.Actions.AddRequestingMemberAction request : actions.getAddRequestingMembersList()) { + builder.addNewRequestingMembers(decryptRequestingMember(request.getAdded())); + } + + // Field 17 + for (GroupChange.Actions.DeleteRequestingMemberAction delete : actions.getDeleteRequestingMembersList()) { + builder.addDeleteRequestingMembers(decryptUuidToByteString(delete.getDeletedUserId())); + } + + // Field 18 + for (GroupChange.Actions.PromoteRequestingMemberAction promote : actions.getPromoteRequestingMembersList()) { + builder.addPromoteRequestingMembers(DecryptedApproveMember.newBuilder().setRole(promote.getRole()).setUuid(decryptUuidToByteString(promote.getUserId()))); + } + + // Field 19 + if (actions.hasModifyInviteLinkPassword()) { + builder.setNewInviteLinkPassword(actions.getModifyInviteLinkPassword().getInviteLinkPassword()); + } + return builder.build(); } + public DecryptedGroupJoinInfo decryptGroupJoinInfo(GroupJoinInfo joinInfo) { + return DecryptedGroupJoinInfo.newBuilder() + .setTitle(decryptTitle(joinInfo.getTitle())) + .setAvatar(joinInfo.getAvatar()) + .setMemberCount(joinInfo.getMemberCount()) + .setAddFromInviteLink(joinInfo.getAddFromInviteLink()) + .setRevision(joinInfo.getRevision()) + .build(); + } + private DecryptedMember.Builder decryptMember(Member member) throws InvalidGroupStateException, VerificationFailedException, InvalidInputException { @@ -453,14 +608,50 @@ public final class GroupsV2Operations { UUID uuid = decryptUuidOrUnknown(userIdCipherText); UUID addedBy = decryptUuid(member.getAddedByUserId()); + Member.Role role = member.getMember().getRole(); + + if (role != Member.Role.ADMINISTRATOR && role != Member.Role.DEFAULT) { + role = Member.Role.DEFAULT; + } + return DecryptedPendingMember.newBuilder() .setUuid(UuidUtil.toByteString(uuid)) .setUuidCipherText(userIdCipherText) .setAddedByUuid(ByteString.copyFrom(UUIDUtil.serialize(addedBy))) - .setRole(member.getMember().getRole()) + .setRole(role) + .setTimestamp(member.getTimestamp()) .build(); } + private DecryptedRequestingMember decryptRequestingMember(RequestingMember member) + throws InvalidGroupStateException, VerificationFailedException + { + if (member.getPresentation().isEmpty()) { + UUID uuid = decryptUuid(member.getUserId()); + + return DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setProfileKey(decryptProfileKeyToByteString(member.getProfileKey(), uuid)) + .setTimestamp(member.getTimestamp()) + .build(); + } else { + ProfileKeyCredentialPresentation profileKeyCredentialPresentation; + try { + profileKeyCredentialPresentation = new ProfileKeyCredentialPresentation(member.getPresentation().toByteArray()); + } catch (InvalidInputException e) { + throw new InvalidGroupStateException(e); + } + + UUID uuid = clientZkGroupCipher.decryptUuid(profileKeyCredentialPresentation.getUuidCiphertext()); + ProfileKey profileKey = clientZkGroupCipher.decryptProfileKey(profileKeyCredentialPresentation.getProfileKeyCiphertext(), uuid); + + return DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setProfileKey(ByteString.copyFrom(profileKey.serialize())) + .build(); + } + } + private ProfileKey decryptProfileKey(ByteString profileKey, UUID uuid) throws VerificationFailedException, InvalidGroupStateException { try { ProfileKeyCiphertext profileKeyCiphertext = new ProfileKeyCiphertext(profileKey.toByteArray()); @@ -501,7 +692,7 @@ public final class GroupsV2Operations { } } - private ByteString encryptTitle(String title) { + ByteString encryptTitle(String title) { try { GroupAttributeBlob blob = GroupAttributeBlob.newBuilder().setTitle(title).build(); @@ -544,7 +735,7 @@ public final class GroupsV2Operations { } } - private ByteString encryptTimer(int timerDurationSeconds) { + ByteString encryptTimer(int timerDurationSeconds) { try { GroupAttributeBlob timer = GroupAttributeBlob.newBuilder() .setDisappearingMessagesDuration(timerDurationSeconds) @@ -584,18 +775,6 @@ public final class GroupsV2Operations { return GroupChange.Actions.parseFrom(groupChange.getActions()); } - public GroupChange.Actions.Builder createChangeMembershipRights(AccessControl.AccessRequired newRights) { - return GroupChange.Actions.newBuilder() - .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.newBuilder() - .setMembersAccess(newRights)); - } - - public GroupChange.Actions.Builder createChangeAttributesRights(AccessControl.AccessRequired newRights) { - return GroupChange.Actions.newBuilder() - .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() 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 78aabc0a3..97ca25280 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 @@ -15,6 +15,7 @@ import org.signal.storageservice.protos.groups.AvatarUploadAttributes; import org.signal.storageservice.protos.groups.Group; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.GroupChanges; +import org.signal.storageservice.protos.groups.GroupJoinInfo; import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.zkgroup.profiles.ProfileKey; @@ -72,6 +73,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResp import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest; import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse; import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; +import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException; import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException; import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; @@ -93,6 +95,7 @@ import org.whispersystems.signalservice.internal.util.concurrent.FutureTransform import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; import org.whispersystems.signalservice.internal.util.concurrent.SettableFuture; import org.whispersystems.util.Base64; +import org.whispersystems.util.Base64UrlSafe; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -102,7 +105,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; @@ -194,8 +196,10 @@ public class PushServiceSocket { private static final String GROUPSV2_CREDENTIAL = "/v1/certificate/group/%d/%d"; private static final String GROUPSV2_GROUP = "/v1/groups/"; + private static final String GROUPSV2_GROUP_PASSWORD = "/v1/groups/?inviteLinkPassword=%s"; private static final String GROUPSV2_GROUP_CHANGES = "/v1/groups/logs/%s"; private static final String GROUPSV2_AVATAR_REQUEST = "/v1/groups/avatar/form"; + private static final String GROUPSV2_GROUP_JOIN = "/v1/groups/join/%s"; private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp"; @@ -1910,6 +1914,9 @@ public class PushServiceSocket { private static final ResponseCodeHandler GROUPS_V2_PATCH_RESPONSE_HANDLER = responseCode -> { if (responseCode == 400) throw new GroupPatchNotAcceptedException(); }; + private static final ResponseCodeHandler GROUPS_V2_GET_JOIN_INFO_HANDLER = responseCode -> { + if (responseCode == 403) throw new ForbiddenException(); + }; public void putNewGroupsV2Group(Group group, GroupsV2AuthorizationString authorization) throws NonSuccessfulResponseCodeException, PushNetworkException @@ -1969,6 +1976,18 @@ public class PushServiceSocket { return GroupChanges.parseFrom(readBodyBytes(response)); } + public GroupJoinInfo getGroupJoinInfo(byte[] groupLinkPassword, GroupsV2AuthorizationString authorization) + throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException + { + ResponseBody response = makeStorageRequest(authorization.toString(), + String.format(GROUPSV2_GROUP_JOIN, Base64UrlSafe.encodeBytesWithoutPadding(groupLinkPassword)), + "GET", + null, + GROUPS_V2_GET_JOIN_INFO_HANDLER); + + return GroupJoinInfo.parseFrom(readBodyBytes(response)); + } + private final class ResumeInfo { private final String contentRange; private final long contentStart; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/ForbiddenException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/ForbiddenException.java new file mode 100644 index 000000000..dddfa86f1 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/ForbiddenException.java @@ -0,0 +1,6 @@ +package org.whispersystems.signalservice.internal.push.exceptions; + +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; + +public final class ForbiddenException extends NonSuccessfulResponseCodeException { +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Base64UrlSafe.java b/libsignal/service/src/main/java/org/whispersystems/util/Base64UrlSafe.java similarity index 52% rename from app/src/main/java/org/thoughtcrime/securesms/util/Base64UrlSafe.java rename to libsignal/service/src/main/java/org/whispersystems/util/Base64UrlSafe.java index d2eac3be2..5c42b7569 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Base64UrlSafe.java +++ b/libsignal/service/src/main/java/org/whispersystems/util/Base64UrlSafe.java @@ -1,8 +1,4 @@ -package org.thoughtcrime.securesms.util; - -import androidx.annotation.NonNull; - -import org.whispersystems.util.Base64; +package org.whispersystems.util; import java.io.IOException; @@ -11,11 +7,11 @@ public final class Base64UrlSafe { private Base64UrlSafe() { } - public static @NonNull byte[] decode(@NonNull String s) throws IOException { + public static byte[] decode(String s) throws IOException { return Base64.decode(s, Base64.URL_SAFE); } - public static @NonNull byte[] decodePaddingAgnostic(@NonNull String s) throws IOException { + public static byte[] decodePaddingAgnostic(String s) throws IOException { switch (s.length() % 4) { case 1: case 3: s = s + "="; break; @@ -24,7 +20,7 @@ public final class Base64UrlSafe { return decode(s); } - public static @NonNull String encodeBytes(@NonNull byte[] source) { + public static String encodeBytes(byte[] source) { try { return Base64.encodeBytes(source, Base64.URL_SAFE); } catch (IOException e) { @@ -32,7 +28,7 @@ public final class Base64UrlSafe { } } - public static @NonNull String encodeBytesWithoutPadding(@NonNull byte[] source) { + public static String encodeBytesWithoutPadding(byte[] source) { return encodeBytes(source).replace("=", ""); } } diff --git a/libsignal/service/src/main/proto/DecryptedGroups.proto b/libsignal/service/src/main/proto/DecryptedGroups.proto index e6bac4ab2..4fad84e5f 100644 --- a/libsignal/service/src/main/proto/DecryptedGroups.proto +++ b/libsignal/service/src/main/proto/DecryptedGroups.proto @@ -27,45 +27,63 @@ message DecryptedPendingMember { bytes uuidCipherText = 5; } +message DecryptedRequestingMember { + bytes uuid = 1; + bytes profileKey = 2; + uint64 timestamp = 4; +} + message DecryptedPendingMemberRemoval { - bytes uuid = 1; - bytes uuidCipherText = 2; + bytes uuid = 1; + bytes uuidCipherText = 2; +} + +message DecryptedApproveMember { + bytes uuid = 1; + Member.Role role = 2; } message DecryptedModifyMemberRole { - bytes uuid = 1; - Member.Role role = 2; + bytes uuid = 1; + Member.Role role = 2; } // Decrypted version of message Group // Keep field numbers in step message DecryptedGroup { - string title = 2; - string avatar = 3; - DecryptedTimer disappearingMessagesTimer = 4; - AccessControl accessControl = 5; - uint32 revision = 6; - repeated DecryptedMember members = 7; - repeated DecryptedPendingMember pendingMembers = 8; + string title = 2; + string avatar = 3; + DecryptedTimer disappearingMessagesTimer = 4; + AccessControl accessControl = 5; + uint32 revision = 6; + repeated DecryptedMember members = 7; + repeated DecryptedPendingMember pendingMembers = 8; + repeated DecryptedRequestingMember requestingMembers = 9; + bytes inviteLinkPassword = 10; } // Decrypted version of message GroupChange.Actions // Keep field numbers in step message DecryptedGroupChange { - bytes editor = 1; - uint32 revision = 2; - repeated DecryptedMember newMembers = 3; - repeated bytes deleteMembers = 4; - repeated DecryptedModifyMemberRole modifyMemberRoles = 5; - repeated DecryptedMember modifiedProfileKeys = 6; - repeated DecryptedPendingMember newPendingMembers = 7; - repeated DecryptedPendingMemberRemoval deletePendingMembers = 8; - repeated DecryptedMember promotePendingMembers = 9; - DecryptedString newTitle = 10; - DecryptedString newAvatar = 11; - DecryptedTimer newTimer = 12; - AccessControl.AccessRequired newAttributeAccess = 13; - AccessControl.AccessRequired newMemberAccess = 14; + bytes editor = 1; + uint32 revision = 2; + repeated DecryptedMember newMembers = 3; + repeated bytes deleteMembers = 4; + repeated DecryptedModifyMemberRole modifyMemberRoles = 5; + repeated DecryptedMember modifiedProfileKeys = 6; + repeated DecryptedPendingMember newPendingMembers = 7; + repeated DecryptedPendingMemberRemoval deletePendingMembers = 8; + repeated DecryptedMember promotePendingMembers = 9; + DecryptedString newTitle = 10; + DecryptedString newAvatar = 11; + DecryptedTimer newTimer = 12; + AccessControl.AccessRequired newAttributeAccess = 13; + AccessControl.AccessRequired newMemberAccess = 14; + AccessControl.AccessRequired newInviteLinkAccess = 15; + repeated DecryptedRequestingMember newRequestingMembers = 16; + repeated bytes deleteRequestingMembers = 17; + repeated DecryptedApproveMember promoteRequestingMembers = 18; + bytes newInviteLinkPassword = 19; } message DecryptedString { @@ -75,3 +93,11 @@ message DecryptedString { message DecryptedTimer { uint32 duration = 1; } + +message DecryptedGroupJoinInfo { + string title = 2; + string avatar = 3; + uint32 memberCount = 4; + AccessControl.AccessRequired addFromInviteLink = 5; + uint32 revision = 6; +} diff --git a/libsignal/service/src/main/proto/Groups.proto b/libsignal/service/src/main/proto/Groups.proto index 80b0d7b83..173bed089 100644 --- a/libsignal/service/src/main/proto/Groups.proto +++ b/libsignal/service/src/main/proto/Groups.proto @@ -38,26 +38,38 @@ message PendingMember { uint64 timestamp = 3; } +message RequestingMember { + bytes userId = 1; + bytes profileKey = 2; + bytes presentation = 3; + uint64 timestamp = 4; +} + message AccessControl { enum AccessRequired { UNKNOWN = 0; + ANY = 1; MEMBER = 2; ADMINISTRATOR = 3; + UNSATISFIABLE = 4; } - AccessRequired attributes = 1; - AccessRequired members = 2; + AccessRequired attributes = 1; + AccessRequired members = 2; + AccessRequired addFromInviteLink = 3; } message Group { - bytes publicKey = 1; - bytes title = 2; - string avatar = 3; - bytes disappearingMessagesTimer = 4; - AccessControl accessControl = 5; - uint32 revision = 6; - repeated Member members = 7; - repeated PendingMember pendingMembers = 8; + bytes publicKey = 1; + bytes title = 2; + string avatar = 3; + bytes disappearingMessagesTimer = 4; + AccessControl accessControl = 5; + uint32 revision = 6; + repeated Member members = 7; + repeated PendingMember pendingMembers = 8; + repeated RequestingMember requestingMembers = 9; + bytes inviteLinkPassword = 10; } message GroupChange { @@ -65,7 +77,8 @@ message GroupChange { message Actions { message AddMemberAction { - Member added = 1; + Member added = 1; + bool joinFromInviteLink = 2; } message DeleteMemberAction { @@ -93,6 +106,19 @@ message GroupChange { bytes presentation = 1; } + message AddRequestingMemberAction { + RequestingMember added = 1; + } + + message DeleteRequestingMemberAction { + bytes deletedUserId = 1; + } + + message PromoteRequestingMemberAction { + bytes userId = 1; + Member.Role role = 2; + } + message ModifyTitleAction { bytes title = 1; } @@ -113,20 +139,33 @@ message GroupChange { AccessControl.AccessRequired membersAccess = 1; } - bytes sourceUuid = 1; - uint32 revision = 2; - repeated AddMemberAction addMembers = 3; - repeated DeleteMemberAction deleteMembers = 4; - repeated ModifyMemberRoleAction modifyMemberRoles = 5; - repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; - repeated AddPendingMemberAction addPendingMembers = 7; - repeated DeletePendingMemberAction deletePendingMembers = 8; - repeated PromotePendingMemberAction promotePendingMembers = 9; - ModifyTitleAction modifyTitle = 10; - ModifyAvatarAction modifyAvatar = 11; - ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; - ModifyAttributesAccessControlAction modifyAttributesAccess = 13; - ModifyMembersAccessControlAction modifyMemberAccess = 14; + message ModifyAddFromInviteLinkAccessControlAction { + AccessControl.AccessRequired addFromInviteLinkAccess = 1; + } + + message ModifyInviteLinkPasswordAction { + bytes inviteLinkPassword = 1; + } + + bytes sourceUuid = 1; + uint32 revision = 2; + repeated AddMemberAction addMembers = 3; + repeated DeleteMemberAction deleteMembers = 4; + repeated ModifyMemberRoleAction modifyMemberRoles = 5; + repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; + repeated AddPendingMemberAction addPendingMembers = 7; + repeated DeletePendingMemberAction deletePendingMembers = 8; + repeated PromotePendingMemberAction promotePendingMembers = 9; + ModifyTitleAction modifyTitle = 10; + ModifyAvatarAction modifyAvatar = 11; + ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; + ModifyAttributesAccessControlAction modifyAttributesAccess = 13; + ModifyMembersAccessControlAction modifyMemberAccess = 14; + ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; + repeated AddRequestingMemberAction addRequestingMembers = 16; + repeated DeleteRequestingMemberAction deleteRequestingMembers = 17; + repeated PromoteRequestingMemberAction promoteRequestingMembers = 18; + ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; } bytes actions = 1; @@ -161,3 +200,12 @@ message GroupInviteLink { GroupInviteLinkContentsV1 v1Contents = 1; } } + +message GroupJoinInfo { + bytes publicKey = 1; + bytes title = 2; + string avatar = 3; + uint32 memberCount = 4; + AccessControl.AccessRequired addFromInviteLink = 5; + uint32 revision = 6; +} 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 index df1cfe2c8..58c767b01 100644 --- 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 @@ -5,16 +5,19 @@ import com.google.protobuf.ByteString; 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.DecryptedApproveMember; 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.DecryptedRequestingMember; import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.util.Util; import java.util.UUID; @@ -23,12 +26,28 @@ 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.newProfileKey; 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.requestingMember; import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey; +import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber; public final class DecryptedGroupUtil_apply_Test { + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be applied by {@link DecryptedGroupUtil#apply}. + */ + @Test + public void ensure_DecryptedGroupUtil_knows_about_all_fields_of_DecryptedGroupChange() { + int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); + + assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), + 19, maxFieldFound); + } + @Test public void apply_revision() throws NotAbleToApplyGroupV2ChangeException { DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() @@ -580,4 +599,168 @@ public final class DecryptedGroupUtil_apply_Test { .build(), newGroup); } + + @Test + public void invite_link_access() throws NotAbleToApplyGroupV2ChangeException { + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(10) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.MEMBER) + .setMembers(AccessControl.AccessRequired.MEMBER) + .setAddFromInviteLink(AccessControl.AccessRequired.UNSATISFIABLE) + .build()) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(11) + .setNewInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(11) + .setAccessControl(AccessControl.newBuilder() + .setAttributes(AccessControl.AccessRequired.MEMBER) + .setMembers(AccessControl.AccessRequired.MEMBER) + .setAddFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR) + .build()) + .build(), + newGroup); + } + + @Test + public void apply_new_requesting_member() throws NotAbleToApplyGroupV2ChangeException { + DecryptedRequestingMember member1 = requestingMember(UUID.randomUUID()); + DecryptedRequestingMember member2 = requestingMember(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(10) + .addRequestingMembers(member1) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(11) + .addNewRequestingMembers(member2) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(11) + .addRequestingMembers(member1) + .addRequestingMembers(member2) + .build(), + newGroup); + } + + @Test + public void apply_remove_requesting_member() throws NotAbleToApplyGroupV2ChangeException { + DecryptedRequestingMember member1 = requestingMember(UUID.randomUUID()); + DecryptedRequestingMember member2 = requestingMember(UUID.randomUUID()); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(13) + .addRequestingMembers(member1) + .addRequestingMembers(member2) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(14) + .addDeleteRequestingMembers(member1.getUuid()) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(14) + .addRequestingMembers(member2) + .build(), + newGroup); + } + + @Test + public void promote_requesting_member() throws NotAbleToApplyGroupV2ChangeException { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + UUID uuid3 = UUID.randomUUID(); + ProfileKey profileKey1 = newProfileKey(); + ProfileKey profileKey2 = newProfileKey(); + ProfileKey profileKey3 = newProfileKey(); + DecryptedRequestingMember member1 = requestingMember(uuid1, profileKey1); + DecryptedRequestingMember member2 = requestingMember(uuid2, profileKey2); + DecryptedRequestingMember member3 = requestingMember(uuid3, profileKey3); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(13) + .addRequestingMembers(member1) + .addRequestingMembers(member2) + .addRequestingMembers(member3) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(14) + .addPromoteRequestingMembers(DecryptedApproveMember.newBuilder() + .setRole(Member.Role.DEFAULT) + .setUuid(member1.getUuid())) + .addPromoteRequestingMembers(DecryptedApproveMember.newBuilder() + .setRole(Member.Role.ADMINISTRATOR) + .setUuid(member2.getUuid())) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(14) + .addMembers(member(uuid1, profileKey1)) + .addMembers(admin(uuid2, profileKey2)) + .addRequestingMembers(member3) + .build(), + newGroup); + } + + @Test(expected = NotAbleToApplyGroupV2ChangeException.class) + public void cannot_apply_promote_requesting_member_without_a_role() throws NotAbleToApplyGroupV2ChangeException { + UUID uuid = UUID.randomUUID(); + DecryptedRequestingMember member = requestingMember(uuid); + + DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(13) + .addRequestingMembers(member) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(14) + .addPromoteRequestingMembers(DecryptedApproveMember.newBuilder() + .setUuid(member.getUuid())) + .build()); + } + + @Test + public void invite_link_password() throws NotAbleToApplyGroupV2ChangeException { + ByteString password1 = ByteString.copyFrom(Util.getSecretBytes(16)); + ByteString password2 = ByteString.copyFrom(Util.getSecretBytes(16)); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(10) + .setInviteLinkPassword(password1) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(11) + .setNewInviteLinkPassword(password2) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(11) + .setInviteLinkPassword(password2) + .build(), + newGroup); + } + + @Test + public void invite_link_password_not_changed() throws NotAbleToApplyGroupV2ChangeException { + ByteString password = ByteString.copyFrom(Util.getSecretBytes(16)); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(10) + .setInviteLinkPassword(password) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(11) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(11) + .setInviteLinkPassword(password) + .build(), + newGroup); + } + } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java index 28cc6fcbd..f2d556560 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_empty_Test.java @@ -1,8 +1,12 @@ package org.whispersystems.signalservice.api.groupsv2; +import com.google.protobuf.ByteString; + import org.junit.Test; import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember; import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.whispersystems.signalservice.api.util.UuidUtil; @@ -31,7 +35,7 @@ public final class DecryptedGroupUtil_empty_Test { int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), - DecryptedGroupUtil.MAX_CHANGE_FIELD, maxFieldFound); + 19, maxFieldFound); } @Test @@ -158,4 +162,54 @@ public final class DecryptedGroupUtil_empty_Test { assertFalse(DecryptedGroupUtil.changeIsEmpty(change)); assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change)); } + + @Test + public void not_empty_with_modify_add_from_invite_link_access_field_15() { + DecryptedGroupChange change = DecryptedGroupChange.newBuilder() + .setNewInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + assertFalse(DecryptedGroupUtil.changeIsEmpty(change)); + assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change)); + } + + @Test + public void not_empty_with_an_add_requesting_member_field_16() { + DecryptedGroupChange change = DecryptedGroupChange.newBuilder() + .addNewRequestingMembers(DecryptedRequestingMember.getDefaultInstance()) + .build(); + + assertFalse(DecryptedGroupUtil.changeIsEmpty(change)); + assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change)); + } + + @Test + public void not_empty_with_a_delete_requesting_member_field_17() { + DecryptedGroupChange change = DecryptedGroupChange.newBuilder() + .addDeleteRequestingMembers(ByteString.copyFrom(new byte[16])) + .build(); + + assertFalse(DecryptedGroupUtil.changeIsEmpty(change)); + assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change)); + } + + @Test + public void not_empty_with_a_promote_requesting_member_field_18() { + DecryptedGroupChange change = DecryptedGroupChange.newBuilder() + .addPromoteRequestingMembers(DecryptedApproveMember.getDefaultInstance()) + .build(); + + assertFalse(DecryptedGroupUtil.changeIsEmpty(change)); + assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change)); + } + + @Test + public void not_empty_with_a_new_invite_link_password_19() { + DecryptedGroupChange change = DecryptedGroupChange.newBuilder() + .setNewInviteLinkPassword(ByteString.copyFrom(new byte[16])) + .build(); + + assertFalse(DecryptedGroupUtil.changeIsEmpty(change)); + assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change)); + } } diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java index 9603b5342..16abf4e30 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java @@ -1,5 +1,7 @@ package org.whispersystems.signalservice.api.groupsv2; +import com.google.protobuf.ByteString; + import org.junit.Test; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.local.DecryptedGroup; @@ -8,21 +10,40 @@ import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.util.Util; 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.approveAdmin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.approveMember; import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin; import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.newProfileKey; 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.promoteAdmin; import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember; import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey; +import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber; public final class GroupChangeReconstructTest { + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be detected by {@link GroupChangeReconstruct#reconstructGroupChange}. + */ + @Test + public void ensure_GroupChangeReconstruct_knows_about_all_fields_of_DecryptedGroup() { + int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class); + + assertEquals("GroupChangeReconstruct and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), + 10, maxFieldFound); + } + @Test public void empty_to_empty() { DecryptedGroup from = DecryptedGroup.newBuilder().build(); @@ -219,4 +240,122 @@ public final class GroupChangeReconstructTest { assertEquals(DecryptedGroupChange.newBuilder().addModifiedProfileKeys(withProfileKey(admin(uuid),profileKey2)).build(), decryptedGroupChange); } + + @Test + public void new_invite_access() { + DecryptedGroup from = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder() + .setAddFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + DecryptedGroup to = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder() + .setAddFromInviteLink(AccessControl.AccessRequired.UNSATISFIABLE)) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder() + .setNewInviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE) + .build(), + decryptedGroupChange); + } + + @Test + public void new_requesting_members() { + UUID member1 = UUID.randomUUID(); + ProfileKey profileKey1 = newProfileKey(); + DecryptedGroup from = DecryptedGroup.newBuilder() + .build(); + DecryptedGroup to = DecryptedGroup.newBuilder() + .addRequestingMembers(requestingMember(member1, profileKey1)) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder() + .addNewRequestingMembers(requestingMember(member1, profileKey1)) + .build(), + decryptedGroupChange); + } + + @Test + public void new_requesting_members_ignores_existing_by_uuid() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + ProfileKey profileKey2 = newProfileKey(); + DecryptedGroup from = DecryptedGroup.newBuilder() + .addRequestingMembers(requestingMember(member1, newProfileKey())) + .build(); + DecryptedGroup to = DecryptedGroup.newBuilder() + .addRequestingMembers(requestingMember(member1, newProfileKey())) + .addRequestingMembers(requestingMember(member2, profileKey2)) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder() + .addNewRequestingMembers(requestingMember(member2, profileKey2)) + .build(), + decryptedGroupChange); + } + + @Test + public void removed_requesting_members() { + UUID member1 = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder() + .addRequestingMembers(requestingMember(member1, newProfileKey())) + .build(); + DecryptedGroup to = DecryptedGroup.newBuilder() + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder() + .addDeleteRequestingMembers(UuidUtil.toByteString(member1)) + .build(), + decryptedGroupChange); + } + + @Test + public void promote_requesting_members() { + UUID member1 = UUID.randomUUID(); + ProfileKey profileKey1 = newProfileKey(); + UUID member2 = UUID.randomUUID(); + ProfileKey profileKey2 = newProfileKey(); + DecryptedGroup from = DecryptedGroup.newBuilder() + .addRequestingMembers(requestingMember(member1, profileKey1)) + .addRequestingMembers(requestingMember(member2, profileKey2)) + .build(); + DecryptedGroup to = DecryptedGroup.newBuilder() + .addMembers(member(member1, profileKey1)) + .addMembers(admin(member2, profileKey2)) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder() + .addPromoteRequestingMembers(approveMember(member1)) + .addPromoteRequestingMembers(approveAdmin(member2)) + .build(), + decryptedGroupChange); + } + + @Test + public void new_invite_link_password() { + ByteString password1 = ByteString.copyFrom(Util.getSecretBytes(16)); + ByteString password2 = ByteString.copyFrom(Util.getSecretBytes(16)); + DecryptedGroup from = DecryptedGroup.newBuilder() + .setInviteLinkPassword(password1) + .build(); + DecryptedGroup to = DecryptedGroup.newBuilder() + .setInviteLinkPassword(password2) + .build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder() + .setNewInviteLinkPassword(password2) + .build(), + decryptedGroupChange); + } } \ No newline at end of file diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtilTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java similarity index 64% rename from libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtilTest.java rename to libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java index 9c41f4b6c..26e5eedbd 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtilTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeUtil_changeIsEmpty_Test.java @@ -8,7 +8,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber; -public final class GroupChangeUtilTest { +public final class GroupChangeUtil_changeIsEmpty_Test { /** * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. @@ -20,7 +20,7 @@ public final class GroupChangeUtilTest { int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class); assertEquals("GroupChangeUtil and its tests need updating to account for new fields on " + GroupChange.Actions.class.getName(), - GroupChangeUtil.CHANGE_ACTION_MAX_FIELD, maxFieldFound); + 19, maxFieldFound); } @Test @@ -31,7 +31,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_add_member_field_3() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder().getDefaultInstanceForType()) + .addAddMembers(GroupChange.Actions.AddMemberAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -40,7 +40,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_delete_member_field_4() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.newBuilder().getDefaultInstanceForType()) + .addDeleteMembers(GroupChange.Actions.DeleteMemberAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -49,7 +49,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_member_roles_field_5() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder().getDefaultInstanceForType()) + .addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -58,7 +58,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_profile_keys_field_6() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.newBuilder().getDefaultInstanceForType()) + .addModifyMemberProfileKeys(GroupChange.Actions.ModifyMemberProfileKeyAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -67,7 +67,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_add_pending_members_field_7() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder().getDefaultInstanceForType()) + .addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -76,7 +76,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_delete_pending_members_field_8() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().getDefaultInstanceForType()) + .addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -85,7 +85,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_promote_delete_pending_members_field_9() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().getDefaultInstanceForType()) + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -94,7 +94,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_title_field_10() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .setModifyTitle(GroupChange.Actions.ModifyTitleAction.newBuilder().getDefaultInstanceForType()) + .setModifyTitle(GroupChange.Actions.ModifyTitleAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -103,7 +103,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_avatar_field_11() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().getDefaultInstanceForType()) + .setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -112,7 +112,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_disappearing_message_timer_field_12() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.newBuilder().getDefaultInstanceForType()) + .setModifyDisappearingMessagesTimer(GroupChange.Actions.ModifyDisappearingMessagesTimerAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -121,7 +121,7 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_attributes_field_13() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.newBuilder().getDefaultInstanceForType()) + .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); @@ -130,7 +130,52 @@ public final class GroupChangeUtilTest { @Test public void not_empty_with_modify_member_access_field_14() { GroupChange.Actions actions = GroupChange.Actions.newBuilder() - .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.newBuilder().getDefaultInstanceForType()) + .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.getDefaultInstance()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_modify_add_from_invite_link_field_15() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyAddFromInviteLinkAccess(GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction.getDefaultInstance()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_add_requesting_members_field_16() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.getDefaultInstance()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_delete_requesting_members_field_17() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addDeleteRequestingMembers(GroupChange.Actions.DeleteRequestingMemberAction.getDefaultInstance()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_promote_requesting_members_field_18() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .addPromoteRequestingMembers(GroupChange.Actions.PromoteRequestingMemberAction.getDefaultInstance()) + .build(); + + assertFalse(GroupChangeUtil.changeIsEmpty(actions)); + } + + @Test + public void not_empty_with_promote_requesting_members_field_19() { + GroupChange.Actions actions = GroupChange.Actions.newBuilder() + .setModifyInviteLinkPassword(GroupChange.Actions.ModifyInviteLinkPasswordAction.getDefaultInstance()) .build(); assertFalse(GroupChangeUtil.changeIsEmpty(actions)); 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 6fa0867bf..3af10ce2d 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 @@ -13,24 +13,69 @@ import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.util.Util; 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.approveMember; 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.encryptedRequestingMember; 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; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember; +import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber; public final class GroupChangeUtil_resolveConflict_Test { + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}. + */ + @Test + public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() { + int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); + + assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(), + 19, maxFieldFound); + } + + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}. + */ + @Test + public void ensure_resolveConflict_knows_about_all_fields_of_GroupChange() { + int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class); + + assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + GroupChange.class.getName(), + 19, maxFieldFound); + } + + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict}. + */ + @Test + public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() { + int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class); + + assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(), + 10, maxFieldFound); + } + + @Test public void empty_actions() { GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(DecryptedGroup.newBuilder().build(), @@ -471,4 +516,160 @@ public final class GroupChangeUtil_resolveConflict_Test { assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); } + + @Test + public void field_15__no_membership_access_change_is_removed() { + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setAccessControl(AccessControl.newBuilder().setAddFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyAddFromInviteLinkAccess(GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction.newBuilder().setAddFromInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions)); + } + + @Test + public void field_16__changes_to_add_requesting_members_when_full_members_are_removed() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + ProfileKey profileKey2 = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1)) + .addMembers(member(member3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addNewRequestingMembers(requestingMember(member1)) + .addNewRequestingMembers(requestingMember(member2)) + .addNewRequestingMembers(requestingMember(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member1, randomProfileKey()))) + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member2, profileKey2))) + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member3, randomProfileKey()))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member2, profileKey2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_16__changes_to_add_requesting_members_when_pending_are_promoted() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + ProfileKey profileKey1 = randomProfileKey(); + ProfileKey profileKey2 = randomProfileKey(); + ProfileKey profileKey3 = randomProfileKey(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addPendingMembers(pendingMember(member1)) + .addPendingMembers(pendingMember(member3)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addNewRequestingMembers(requestingMember(member1, profileKey1)) + .addNewRequestingMembers(requestingMember(member2, profileKey2)) + .addNewRequestingMembers(requestingMember(member3, profileKey3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member1, profileKey1))) + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member2, profileKey2))) + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member3, profileKey3))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member1, profileKey1))) + .addAddRequestingMembers(GroupChange.Actions.AddRequestingMemberAction.newBuilder().setAdded(encryptedRequestingMember(member2, profileKey2))) + .addPromotePendingMembers(GroupChange.Actions.PromotePendingMemberAction.newBuilder().setPresentation(presentation(member3, profileKey3))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_17__changes_to_remove_missing_requesting_members_are_excluded() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addRequestingMembers(requestingMember(member2)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addDeleteRequestingMembers(UuidUtil.toByteString(member1)) + .addDeleteRequestingMembers(UuidUtil.toByteString(member2)) + .addDeleteRequestingMembers(UuidUtil.toByteString(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addDeleteRequestingMembers(GroupChange.Actions.DeleteRequestingMemberAction.newBuilder().setDeletedUserId(encrypt(member1))) + .addDeleteRequestingMembers(GroupChange.Actions.DeleteRequestingMemberAction.newBuilder().setDeletedUserId(encrypt(member2))) + .addDeleteRequestingMembers(GroupChange.Actions.DeleteRequestingMemberAction.newBuilder().setDeletedUserId(encrypt(member3))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addDeleteRequestingMembers(GroupChange.Actions.DeleteRequestingMemberAction.newBuilder().setDeletedUserId(encrypt(member2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_18__promote_requesting_members() { + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID member3 = UUID.randomUUID(); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .addMembers(member(member1)) + .addRequestingMembers(requestingMember(member2)) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .addPromoteRequestingMembers(approveMember(member1)) + .addPromoteRequestingMembers(approveMember(member2)) + .addPromoteRequestingMembers(approveMember(member3)) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .addPromoteRequestingMembers(GroupChange.Actions.PromoteRequestingMemberAction.newBuilder().setRole(Member.Role.DEFAULT).setUserId(UuidUtil.toByteString(member1))) + .addPromoteRequestingMembers(GroupChange.Actions.PromoteRequestingMemberAction.newBuilder().setRole(Member.Role.DEFAULT).setUserId(UuidUtil.toByteString(member2))) + .addPromoteRequestingMembers(GroupChange.Actions.PromoteRequestingMemberAction.newBuilder().setRole(Member.Role.DEFAULT).setUserId(UuidUtil.toByteString(member3))) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .addPromoteRequestingMembers(GroupChange.Actions.PromoteRequestingMemberAction.newBuilder().setRole(Member.Role.DEFAULT).setUserId(UuidUtil.toByteString(member2))) + .build(); + assertEquals(expected, resolvedActions); + } + + @Test + public void field_19__password_change_is_kept() { + ByteString password1 = ByteString.copyFrom(Util.getSecretBytes(16)); + ByteString password2 = ByteString.copyFrom(Util.getSecretBytes(16)); + DecryptedGroup groupState = DecryptedGroup.newBuilder() + .setInviteLinkPassword(password1) + .build(); + DecryptedGroupChange decryptedChange = DecryptedGroupChange.newBuilder() + .setNewInviteLinkPassword(password2) + .build(); + GroupChange.Actions change = GroupChange.Actions.newBuilder() + .setModifyInviteLinkPassword(GroupChange.Actions.ModifyInviteLinkPasswordAction.newBuilder().setInviteLinkPassword(password2)) + .build(); + + GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build(); + + GroupChange.Actions expected = GroupChange.Actions.newBuilder() + .setModifyInviteLinkPassword(GroupChange.Actions.ModifyInviteLinkPasswordAction.newBuilder().setInviteLinkPassword(password2)) + .build(); + assertEquals(expected, resolvedActions); + } } \ No newline at end of file diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java index d30d83da3..01f38a56e 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_change_Test.java @@ -8,11 +8,13 @@ import org.junit.Test; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.InvalidInputException; @@ -93,6 +95,23 @@ public final class GroupsV2Operations_decrypt_change_Test { .setUuid(UuidUtil.toByteString(newMember)))); } + @Test + public void can_decrypt_member_direct_join_field3() { + UUID newMember = UUID.randomUUID(); + ProfileKey profileKey = newProfileKey(); + GroupCandidate groupCandidate = groupCandidate(newMember, profileKey); + + assertDecryption(groupOperations.createGroupJoinDirect(groupCandidate.getProfileKeyCredential().get()) + .setRevision(10), + DecryptedGroupChange.newBuilder() + .setRevision(10) + .addNewMembers(DecryptedMember.newBuilder() + .setRole(Member.Role.DEFAULT) + .setProfileKey(ByteString.copyFrom(profileKey.serialize())) + .setJoinedAtRevision(10) + .setUuid(UuidUtil.toByteString(newMember)))); + } + @Test public void can_decrypt_member_additions_direct_to_admin_field3() { UUID self = UUID.randomUUID(); @@ -266,20 +285,80 @@ public final class GroupsV2Operations_decrypt_change_Test { @Test public void can_pass_through_new_attribute_access_rights_field_13() { - assertDecryption(GroupChange.Actions.newBuilder() - .setModifyAttributesAccess(GroupChange.Actions.ModifyAttributesAccessControlAction.newBuilder() - .setAttributesAccess(AccessControl.AccessRequired.MEMBER)), - DecryptedGroupChange.newBuilder() - .setNewAttributeAccess(AccessControl.AccessRequired.MEMBER)); + assertDecryption(groupOperations.createChangeAttributesRights(AccessControl.AccessRequired.MEMBER), + DecryptedGroupChange.newBuilder() + .setNewAttributeAccess(AccessControl.AccessRequired.MEMBER)); } @Test public void can_pass_through_new_membership_rights_field_14() { + assertDecryption(groupOperations.createChangeMembershipRights(AccessControl.AccessRequired.ADMINISTRATOR), + DecryptedGroupChange.newBuilder() + .setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)); + } + + @Test + public void can_pass_through_new_add_by_invite_link_rights_field_15() { + assertDecryption(groupOperations.createChangeJoinByLinkRights(AccessControl.AccessRequired.ADMINISTRATOR), + DecryptedGroupChange.newBuilder() + .setNewInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)); + } + + @Test + public void can_pass_through_new_add_by_invite_link_rights_field_15_unsatisfiable() { + assertDecryption(groupOperations.createChangeJoinByLinkRights(AccessControl.AccessRequired.UNSATISFIABLE), + DecryptedGroupChange.newBuilder() + .setNewInviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE)); + } + + @Test + public void can_decrypt_member_requests_field16() { + UUID newRequestingMember = UUID.randomUUID(); + ProfileKey profileKey = newProfileKey(); + GroupCandidate groupCandidate = groupCandidate(newRequestingMember, profileKey); + + assertDecryption(groupOperations.createGroupJoinRequest(groupCandidate.getProfileKeyCredential().get()) + .setRevision(10), + DecryptedGroupChange.newBuilder() + .setRevision(10) + .addNewRequestingMembers(DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(newRequestingMember)) + .setProfileKey(ByteString.copyFrom(profileKey.serialize())))); + } + + @Test + public void can_decrypt_member_requests_refusals_field17() { + UUID newRequestingMember = UUID.randomUUID(); + + assertDecryption(groupOperations.createRefuseGroupJoinRequest(Collections.singleton(newRequestingMember)) + .setRevision(10), + DecryptedGroupChange.newBuilder() + .setRevision(10) + .addDeleteRequestingMembers(UuidUtil.toByteString(newRequestingMember))); + } + + @Test + public void can_decrypt_promote_requesting_members_field18() { + UUID newRequestingMember = UUID.randomUUID(); + + assertDecryption(groupOperations.createApproveGroupJoinRequest(Collections.singleton(newRequestingMember)) + .setRevision(15), + DecryptedGroupChange.newBuilder() + .setRevision(15) + .addPromoteRequestingMembers(DecryptedApproveMember.newBuilder() + .setRole(Member.Role.DEFAULT) + .setUuid(UuidUtil.toByteString(newRequestingMember)))); + } + + @Test + public void can_pass_through_new_invite_link_password_field19() { + byte[] newPassword = Util.getSecretBytes(16); + assertDecryption(GroupChange.Actions.newBuilder() - .setModifyMemberAccess(GroupChange.Actions.ModifyMembersAccessControlAction.newBuilder() - .setMembersAccess(AccessControl.AccessRequired.ADMINISTRATOR)), + .setModifyInviteLinkPassword(GroupChange.Actions.ModifyInviteLinkPasswordAction.newBuilder() + .setInviteLinkPassword(ByteString.copyFrom(newPassword))), DecryptedGroupChange.newBuilder() - .setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)); + .setNewInviteLinkPassword(ByteString.copyFrom(newPassword))); } private static ProfileKey newProfileKey() { diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_groupJoinInfo_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_groupJoinInfo_Test.java new file mode 100644 index 000000000..8509d47d3 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_groupJoinInfo_Test.java @@ -0,0 +1,110 @@ +package org.whispersystems.signalservice.api.groupsv2; + +import org.junit.Before; +import org.junit.Test; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.GroupJoinInfo; +import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.signalservice.testutil.ZkGroupLibraryUtil; + +import static org.junit.Assert.assertEquals; +import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber; + +public final class GroupsV2Operations_decrypt_groupJoinInfo_Test { + + private GroupsV2Operations.GroupOperations groupOperations; + + @Before + public void setup() throws InvalidInputException { + ZkGroupLibraryUtil.assumeZkGroupSupportedOnOS(); + + TestZkGroupServer server = new TestZkGroupServer(); + ClientZkOperations clientZkOperations = new ClientZkOperations(server.getServerPublicParams()); + GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(new GroupMasterKey(Util.getSecretBytes(32))); + + groupOperations = new GroupsV2Operations(clientZkOperations).forGroup(groupSecretParams); + } + + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be decrypted by {@link GroupsV2Operations.GroupOperations#decryptGroupJoinInfo}. + */ + @Test + public void ensure_GroupOperations_knows_about_all_fields_of_Group() { + int maxFieldFound = getMaxDeclaredFieldNumber(GroupJoinInfo.class); + + assertEquals("GroupOperations and its tests need updating to account for new fields on " + GroupJoinInfo.class.getName(), + 6, maxFieldFound); + } + + @Test + public void decrypt_title_field_2() { + GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder() + .setTitle(groupOperations.encryptTitle("Title!")) + .build(); + + DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo); + + assertEquals("Title!", decryptedGroupJoinInfo.getTitle()); + } + + @Test + public void avatar_field_passed_through_3() { + GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder() + .setAvatar("AvatarCdnKey") + .build(); + + DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo); + + assertEquals("AvatarCdnKey", decryptedGroupJoinInfo.getAvatar()); + } + + @Test + public void member_count_passed_through_4() { + GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder() + .setMemberCount(97) + .build(); + + DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo); + + assertEquals(97, decryptedGroupJoinInfo.getMemberCount()); + } + + @Test + public void add_from_invite_link_access_control_passed_though_5_administrator() { + GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder() + .setAddFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR) + .build(); + + DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo); + + assertEquals(AccessControl.AccessRequired.ADMINISTRATOR, decryptedGroupJoinInfo.getAddFromInviteLink()); + } + + @Test + public void add_from_invite_link_access_control_passed_though_5_any() { + GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder() + .setAddFromInviteLink(AccessControl.AccessRequired.ANY) + .build(); + + DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo); + + assertEquals(AccessControl.AccessRequired.ANY, decryptedGroupJoinInfo.getAddFromInviteLink()); + } + + @Test + public void revision_passed_though_6() { + GroupJoinInfo groupJoinInfo = GroupJoinInfo.newBuilder() + .setRevision(11) + .build(); + + DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo); + + assertEquals(11, decryptedGroupJoinInfo.getRevision()); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java new file mode 100644 index 000000000..9c3f0a009 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations_decrypt_group_Test.java @@ -0,0 +1,286 @@ +package org.whispersystems.signalservice.api.groupsv2; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.junit.Before; +import org.junit.Test; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.Group; +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.RequestingMember; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; +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.DecryptedRequestingMember; +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.VerificationFailedException; +import org.signal.zkgroup.groups.ClientZkGroupCipher; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.signal.zkgroup.groups.UuidCiphertext; +import org.signal.zkgroup.profiles.ClientZkProfileOperations; +import org.signal.zkgroup.profiles.ProfileKey; +import org.signal.zkgroup.profiles.ProfileKeyCommitment; +import org.signal.zkgroup.profiles.ProfileKeyCredential; +import org.signal.zkgroup.profiles.ProfileKeyCredentialPresentation; +import org.signal.zkgroup.profiles.ProfileKeyCredentialRequest; +import org.signal.zkgroup.profiles.ProfileKeyCredentialRequestContext; +import org.signal.zkgroup.profiles.ProfileKeyCredentialResponse; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.util.Util; +import org.whispersystems.signalservice.testutil.ZkGroupLibraryUtil; + +import java.util.Collections; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber; + +public final class GroupsV2Operations_decrypt_group_Test { + + private GroupSecretParams groupSecretParams; + private GroupsV2Operations.GroupOperations groupOperations; + + @Before + public void setup() throws InvalidInputException { + ZkGroupLibraryUtil.assumeZkGroupSupportedOnOS(); + + TestZkGroupServer server = new TestZkGroupServer(); + ClientZkOperations clientZkOperations = new ClientZkOperations(server.getServerPublicParams()); + + groupSecretParams = GroupSecretParams.deriveFromMasterKey(new GroupMasterKey(Util.getSecretBytes(32))); + groupOperations = new GroupsV2Operations(clientZkOperations).forGroup(groupSecretParams); + } + + /** + * Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this. + *

+ * If we didn't, newly added fields would not be decrypted by {@link GroupsV2Operations.GroupOperations#decryptGroup}. + */ + @Test + public void ensure_GroupOperations_knows_about_all_fields_of_Group() { + int maxFieldFound = getMaxDeclaredFieldNumber(Group.class); + + assertEquals("GroupOperations and its tests need updating to account for new fields on " + Group.class.getName(), + 10, maxFieldFound); + } + + @Test + public void decrypt_title_field_2() throws VerificationFailedException, InvalidGroupStateException { + Group group = Group.newBuilder() + .setTitle(groupOperations.encryptTitle("Title!")) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals("Title!", decryptedGroup.getTitle()); + } + + @Test + public void avatar_field_passed_through_3() throws VerificationFailedException, InvalidGroupStateException { + Group group = Group.newBuilder() + .setAvatar("AvatarCdnKey") + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals("AvatarCdnKey", decryptedGroup.getAvatar()); + } + + @Test + public void decrypt_message_timer_field_4() throws VerificationFailedException, InvalidGroupStateException { + Group group = Group.newBuilder() + .setDisappearingMessagesTimer(groupOperations.encryptTimer(123)) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(123, decryptedGroup.getDisappearingMessagesTimer().getDuration()); + } + + @Test + public void pass_through_access_control_field_5() throws VerificationFailedException, InvalidGroupStateException { + AccessControl accessControl = AccessControl.newBuilder() + .setMembers(AccessControl.AccessRequired.ADMINISTRATOR) + .setAttributes(AccessControl.AccessRequired.MEMBER) + .setAddFromInviteLink(AccessControl.AccessRequired.UNSATISFIABLE) + .build(); + Group group = Group.newBuilder() + .setAccessControl(accessControl) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(accessControl, decryptedGroup.getAccessControl()); + } + + @Test + public void set_revision_field_6() throws VerificationFailedException, InvalidGroupStateException { + Group group = Group.newBuilder() + .setRevision(99) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(99, decryptedGroup.getRevision()); + } + + @Test + public void decrypt_full_members_field_7() throws VerificationFailedException, InvalidGroupStateException { + UUID admin1 = UUID.randomUUID(); + UUID member1 = UUID.randomUUID(); + ProfileKey adminProfileKey = newProfileKey(); + ProfileKey memberProfileKey = newProfileKey(); + + Group group = Group.newBuilder() + .addMembers(Member.newBuilder() + .setRole(Member.Role.ADMINISTRATOR) + .setUserId(groupOperations.encryptUuid(admin1)) + .setJoinedAtRevision(4) + .setProfileKey(encryptProfileKey(admin1, adminProfileKey))) + .addMembers(Member.newBuilder() + .setRole(Member.Role.DEFAULT) + .setUserId(groupOperations.encryptUuid(member1)) + .setJoinedAtRevision(7) + .setProfileKey(encryptProfileKey(member1, memberProfileKey))) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(DecryptedGroup.newBuilder() + .addMembers(DecryptedMember.newBuilder() + .setJoinedAtRevision(4) + .setUuid(UuidUtil.toByteString(admin1)) + .setRole(Member.Role.ADMINISTRATOR) + .setProfileKey(ByteString.copyFrom(adminProfileKey.serialize()))) + .addMembers(DecryptedMember.newBuilder() + .setJoinedAtRevision(7) + .setRole(Member.Role.DEFAULT) + .setUuid(UuidUtil.toByteString(member1)) + .setProfileKey(ByteString.copyFrom(memberProfileKey.serialize()))) + .build().getMembersList(), + decryptedGroup.getMembersList()); + } + + @Test + public void decrypt_pending_members_field_8() throws VerificationFailedException, InvalidGroupStateException { + UUID admin1 = UUID.randomUUID(); + UUID member1 = UUID.randomUUID(); + UUID member2 = UUID.randomUUID(); + UUID inviter1 = UUID.randomUUID(); + UUID inviter2 = UUID.randomUUID(); + + Group group = Group.newBuilder() + .addPendingMembers(PendingMember.newBuilder() + .setAddedByUserId(groupOperations.encryptUuid(inviter1)) + .setTimestamp(100) + .setMember(Member.newBuilder() + .setRole(Member.Role.ADMINISTRATOR) + .setUserId(groupOperations.encryptUuid(admin1)))) + .addPendingMembers(PendingMember.newBuilder() + .setAddedByUserId(groupOperations.encryptUuid(inviter1)) + .setTimestamp(200) + .setMember(Member.newBuilder() + .setRole(Member.Role.DEFAULT) + .setUserId(groupOperations.encryptUuid(member1)))) + .addPendingMembers(PendingMember.newBuilder() + .setAddedByUserId(groupOperations.encryptUuid(inviter2)) + .setTimestamp(1500) + .setMember(Member.newBuilder() + .setUserId(groupOperations.encryptUuid(member2)))) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(DecryptedGroup.newBuilder() + .addPendingMembers(DecryptedPendingMember.newBuilder() + .setUuid(UuidUtil.toByteString(admin1)) + .setUuidCipherText(groupOperations.encryptUuid(admin1)) + .setTimestamp(100) + .setAddedByUuid(UuidUtil.toByteString(inviter1)) + .setRole(Member.Role.ADMINISTRATOR)) + .addPendingMembers(DecryptedPendingMember.newBuilder() + .setUuid(UuidUtil.toByteString(member1)) + .setUuidCipherText(groupOperations.encryptUuid(member1)) + .setTimestamp(200) + .setAddedByUuid(UuidUtil.toByteString(inviter1)) + .setRole(Member.Role.DEFAULT)) + .addPendingMembers(DecryptedPendingMember.newBuilder() + .setUuid(UuidUtil.toByteString(member2)) + .setUuidCipherText(groupOperations.encryptUuid(member2)) + .setTimestamp(1500) + .setAddedByUuid(UuidUtil.toByteString(inviter2)) + .setRole(Member.Role.DEFAULT)) + .build().getPendingMembersList(), + decryptedGroup.getPendingMembersList()); + } + + @Test + public void decrypt_requesting_members_field_9() throws VerificationFailedException, InvalidGroupStateException { + UUID admin1 = UUID.randomUUID(); + UUID member1 = UUID.randomUUID(); + ProfileKey adminProfileKey = newProfileKey(); + ProfileKey memberProfileKey = newProfileKey(); + + Group group = Group.newBuilder() + .addRequestingMembers(RequestingMember.newBuilder() + .setUserId(groupOperations.encryptUuid(admin1)) + .setProfileKey(encryptProfileKey(admin1, adminProfileKey)) + .setTimestamp(5000)) + .addRequestingMembers(RequestingMember.newBuilder() + .setUserId(groupOperations.encryptUuid(member1)) + .setProfileKey(encryptProfileKey(member1, memberProfileKey)) + .setTimestamp(15000)) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(DecryptedGroup.newBuilder() + .addRequestingMembers(DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(admin1)) + .setProfileKey(ByteString.copyFrom(adminProfileKey.serialize())) + .setTimestamp(5000)) + .addRequestingMembers(DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(member1)) + .setProfileKey(ByteString.copyFrom(memberProfileKey.serialize())) + .setTimestamp(15000)) + .build().getRequestingMembersList(), + decryptedGroup.getRequestingMembersList()); + } + + @Test + public void pass_through_group_link_password_field_10() throws VerificationFailedException, InvalidGroupStateException { + ByteString password = ByteString.copyFrom(Util.getSecretBytes(16)); + Group group = Group.newBuilder() + .setInviteLinkPassword(password) + .build(); + + DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group); + + assertEquals(password, decryptedGroup.getInviteLinkPassword()); + } + + private ByteString encryptProfileKey(UUID uuid, ProfileKey profileKey) { + return ByteString.copyFrom(new ClientZkGroupCipher(groupSecretParams).encryptProfileKey(profileKey, uuid).serialize()); + } + + private static ProfileKey newProfileKey() { + try { + return new ProfileKey(Util.getSecretBytes(32)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } +} \ 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 index 743185bd2..042415d9a 100644 --- 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 @@ -3,13 +3,17 @@ package org.whispersystems.signalservice.api.groupsv2; import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.Member; +import org.signal.storageservice.protos.groups.RequestingMember; +import org.signal.storageservice.protos.groups.local.DecryptedApproveMember; 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.DecryptedRequestingMember; import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.util.Util; import java.security.SecureRandom; import java.util.Arrays; @@ -70,6 +74,12 @@ final class ProtoTestUtils { .build(); } + static RequestingMember encryptedRequestingMember(UUID uuid, ProfileKey profileKey) { + return RequestingMember.newBuilder() + .setPresentation(presentation(uuid, profileKey)) + .build(); + } + static DecryptedMember member(UUID uuid) { return DecryptedMember.newBuilder() .setUuid(UuidUtil.toByteString(uuid)) @@ -101,6 +111,32 @@ final class ProtoTestUtils { .build(); } + static DecryptedRequestingMember requestingMember(UUID uuid) { + return requestingMember(uuid, newProfileKey()); + } + + static DecryptedRequestingMember requestingMember(UUID uuid, ProfileKey profileKey) { + return DecryptedRequestingMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setProfileKey(ByteString.copyFrom(profileKey.serialize())) + .build(); + } + + static DecryptedApproveMember approveMember(UUID uuid) { + return approve(uuid, Member.Role.DEFAULT); + } + + static DecryptedApproveMember approveAdmin(UUID uuid) { + return approve(uuid, Member.Role.ADMINISTRATOR); + } + + private static DecryptedApproveMember approve(UUID uuid, Member.Role role) { + return DecryptedApproveMember.newBuilder() + .setUuid(UuidUtil.toByteString(uuid)) + .setRole(role) + .build(); + } + static DecryptedMember member(UUID uuid, ProfileKey profileKey) { return withProfileKey(member(uuid), profileKey); } @@ -135,4 +171,12 @@ final class ProtoTestUtils { .setRole(Member.Role.DEFAULT) .build(); } + + public static ProfileKey newProfileKey() { + try { + return new ProfileKey(Util.getSecretBytes(32)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/Base64UrlSafeTest.java b/libsignal/service/src/test/java/org/whispersystems/util/Base64UrlSafeTest.java similarity index 95% rename from app/src/test/java/org/thoughtcrime/securesms/util/Base64UrlSafeTest.java rename to libsignal/service/src/test/java/org/whispersystems/util/Base64UrlSafeTest.java index 59d0ae4d5..be61b6197 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/Base64UrlSafeTest.java +++ b/libsignal/service/src/test/java/org/whispersystems/util/Base64UrlSafeTest.java @@ -1,8 +1,9 @@ -package org.thoughtcrime.securesms.util; +package org.whispersystems.util; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.whispersystems.signalservice.internal.util.Hex; import java.io.IOException; import java.util.Arrays;