From 944c3327ee4ea552d968fca1db89fa3318cec2d9 Mon Sep 17 00:00:00 2001 From: AsamK Date: Thu, 26 Aug 2021 08:47:02 +0200 Subject: [PATCH] Extract GroupHelper --- .../org/asamk/signal/manager/Manager.java | 645 +----------------- .../signal/manager/helper/GroupHelper.java | 628 +++++++++++++++++ .../signal/manager/helper/GroupV2Helper.java | 40 +- 3 files changed, 692 insertions(+), 621 deletions(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index 577badc8..3857d803 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -34,6 +34,7 @@ import org.asamk.signal.manager.groups.GroupPermission; import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.manager.groups.LastGroupAdminException; import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.manager.helper.GroupHelper; import org.asamk.signal.manager.helper.GroupV2Helper; import org.asamk.signal.manager.helper.PinHelper; import org.asamk.signal.manager.helper.ProfileHelper; @@ -45,7 +46,6 @@ import org.asamk.signal.manager.jobs.RetrieveStickerPackJob; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.groups.GroupInfo; import org.asamk.signal.manager.storage.groups.GroupInfoV1; -import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.identities.IdentityInfo; import org.asamk.signal.manager.storage.identities.TrustNewIdentity; import org.asamk.signal.manager.storage.messageCache.CachedMessage; @@ -60,23 +60,9 @@ import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.ProfileUtils; import org.asamk.signal.manager.util.StickerUtils; import org.asamk.signal.manager.util.Utils; -import org.signal.libsignal.metadata.InvalidMetadataMessageException; -import org.signal.libsignal.metadata.InvalidMetadataVersionException; -import org.signal.libsignal.metadata.ProtocolDuplicateMessageException; -import org.signal.libsignal.metadata.ProtocolInvalidKeyException; -import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException; import org.signal.libsignal.metadata.ProtocolInvalidMessageException; -import org.signal.libsignal.metadata.ProtocolInvalidVersionException; -import org.signal.libsignal.metadata.ProtocolLegacyMessageException; -import org.signal.libsignal.metadata.ProtocolNoSessionException; import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; -import org.signal.libsignal.metadata.SelfSendException; -import org.signal.storageservice.protos.groups.GroupChange; -import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.zkgroup.InvalidInputException; -import org.signal.zkgroup.VerificationFailedException; -import org.signal.zkgroup.groups.GroupMasterKey; -import org.signal.zkgroup.groups.GroupSecretParams; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.slf4j.Logger; @@ -93,11 +79,9 @@ import org.whispersystems.libsignal.state.PreKeyRecord; import org.whispersystems.libsignal.state.SignedPreKeyRecord; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.InvalidMessageStructureException; import org.whispersystems.signalservice.api.SignalSessionLock; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; -import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; @@ -107,7 +91,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; -import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; @@ -125,18 +108,15 @@ import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.push.exceptions.ConflictException; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.api.util.InvalidNumberException; import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; -import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException; import org.whispersystems.signalservice.internal.contacts.crypto.Quote; import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException; import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; -import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; import org.whispersystems.signalservice.internal.util.DynamicCredentialsProvider; import org.whispersystems.signalservice.internal.util.Hex; import org.whispersystems.signalservice.internal.util.Util; @@ -187,9 +167,9 @@ public class Manager implements Closeable { private final ExecutorService executor = Executors.newCachedThreadPool(); private final ProfileHelper profileHelper; - private final GroupV2Helper groupV2Helper; private final PinHelper pinHelper; private final SendHelper sendHelper; + private final GroupHelper groupHelper; private final AvatarStore avatarStore; private final AttachmentStore attachmentStore; @@ -235,12 +215,11 @@ public class Manager implements Closeable { dependencies::getProfileService, dependencies::getMessageReceiver, this::resolveSignalServiceAddress); - this.groupV2Helper = new GroupV2Helper(this::getRecipientProfileKeyCredential, + final GroupV2Helper groupV2Helper = new GroupV2Helper(this::getRecipientProfileKeyCredential, this::getRecipientProfile, account::getSelfRecipientId, dependencies.getGroupsV2Operations(), dependencies.getGroupsV2Api(), - this::getGroupAuthForToday, this::resolveSignalServiceAddress); this.avatarStore = new AvatarStore(pathConfig.getAvatarsPath()); this.attachmentStore = new AttachmentStore(pathConfig.getAttachmentsPath()); @@ -253,6 +232,13 @@ public class Manager implements Closeable { this::handleIdentityFailure, this::getGroup, this::refreshRegisteredUser); + this.groupHelper = new GroupHelper(account, + dependencies, + sendHelper, + groupV2Helper, + avatarStore, + this::resolveSignalServiceAddress, + this::resolveRecipient); } public String getUsername() { @@ -665,15 +651,6 @@ public class Manager implements Closeable { return ProfileUtils.decryptProfile(profileKey, encryptedProfile); } - private Optional createGroupAvatarAttachment(GroupId groupId) throws IOException { - final var streamDetails = avatarStore.retrieveGroupAvatar(groupId); - if (streamDetails == null) { - return Optional.absent(); - } - - return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); - } - private Optional createContactAvatarAttachment(SignalServiceAddress address) throws IOException { final var streamDetails = avatarStore.retrieveContactAvatar(address); if (streamDetails == null) { @@ -683,17 +660,6 @@ public class Manager implements Closeable { return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); } - private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { - var g = getGroup(groupId); - if (g == null) { - throw new GroupNotFoundException(groupId); - } - if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) { - throw new NotAGroupMemberException(groupId, g.getTitle()); - } - return g; - } - public List getGroups() { return account.getGroupStore().getGroups(); } @@ -701,53 +667,8 @@ public class Manager implements Closeable { public SendGroupMessageResults sendQuitGroupMessage( GroupId groupId, Set groupAdmins ) throws GroupNotFoundException, IOException, NotAGroupMemberException, LastGroupAdminException { - var group = getGroupForUpdating(groupId); - if (group instanceof GroupInfoV1) { - return quitGroupV1((GroupInfoV1) group); - } - final var newAdmins = getRecipientIds(groupAdmins); - try { - return quitGroupV2((GroupInfoV2) group, newAdmins); - } catch (ConflictException e) { - // Detected conflicting update, refreshing group and trying again - group = getGroup(groupId, true); - return quitGroupV2((GroupInfoV2) group, newAdmins); - } - } - - private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException { - var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT) - .withId(groupInfoV1.getGroupId().serialize()) - .build(); - - var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group); - groupInfoV1.removeMember(account.getSelfRecipientId()); - account.getGroupStore().updateGroup(groupInfoV1); - return sendGroupMessage(messageBuilder, - groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId())); - } - - private SendGroupMessageResults quitGroupV2( - final GroupInfoV2 groupInfoV2, final Set newAdmins - ) throws LastGroupAdminException, IOException { - final var currentAdmins = groupInfoV2.getAdminMembers(); - newAdmins.removeAll(currentAdmins); - newAdmins.retainAll(groupInfoV2.getMembers()); - if (currentAdmins.contains(getSelfRecipientId()) - && currentAdmins.size() == 1 - && groupInfoV2.getMembers().size() > 1 - && newAdmins.size() == 0) { - // Last admin can't leave the group, unless she's also the last member - throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle()); - } - final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins); - groupInfoV2.setGroup(groupGroupChangePair.first(), this::resolveRecipient); - account.getGroupStore().updateGroup(groupInfoV2); - - var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray()); - return sendGroupMessage(messageBuilder, - groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId())); + return groupHelper.quitGroup(groupId, newAdmins); } public void deleteGroup(GroupId groupId) throws IOException { @@ -758,45 +679,7 @@ public class Manager implements Closeable { public Pair createGroup( String name, Set members, File avatarFile ) throws IOException, AttachmentInvalidException { - return createGroupInternal(name, members == null ? null : getRecipientIds(members), avatarFile); - } - - private Pair createGroupInternal( - String name, Set members, File avatarFile - ) throws IOException, AttachmentInvalidException { - final var selfRecipientId = account.getSelfRecipientId(); - if (members != null && members.contains(selfRecipientId)) { - members = new HashSet<>(members); - members.remove(selfRecipientId); - } - - var gv2Pair = groupV2Helper.createGroup(name == null ? "" : name, - members == null ? Set.of() : members, - avatarFile); - - if (gv2Pair == null) { - // Failed to create v2 group, creating v1 group instead - var gv1 = new GroupInfoV1(GroupIdV1.createRandom()); - gv1.addMembers(List.of(selfRecipientId)); - final var result = updateGroupV1(gv1, name, members, avatarFile); - return new Pair<>(gv1.getGroupId(), result); - } - - final var gv2 = gv2Pair.first(); - final var decryptedGroup = gv2Pair.second(); - - gv2.setGroup(decryptedGroup, this::resolveRecipient); - if (avatarFile != null) { - avatarStore.storeGroupAvatar(gv2.getGroupId(), - outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); - } - - account.getGroupStore().updateGroup(gv2); - - final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null); - - final var result = sendGroupMessage(messageBuilder, gv2.getMembersIncludingPendingWithout(selfRecipientId)); - return new Pair<>(gv2.getGroupId(), result); + return groupHelper.createGroup(name, members == null ? null : getRecipientIds(members), avatarFile); } public SendGroupMessageResults updateGroup( @@ -815,7 +698,7 @@ public class Manager implements Closeable { Integer expirationTimer, Boolean isAnnouncementGroup ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { - return updateGroupInternal(groupId, + return groupHelper.updateGroup(groupId, name, description, members == null ? null : getRecipientIds(members), @@ -831,267 +714,10 @@ public class Manager implements Closeable { isAnnouncementGroup); } - private SendGroupMessageResults updateGroupInternal( - final GroupId groupId, - final String name, - final String description, - final Set members, - final Set removeMembers, - final Set admins, - final Set removeAdmins, - final boolean resetGroupLink, - final GroupLinkState groupLinkState, - final GroupPermission addMemberPermission, - final GroupPermission editDetailsPermission, - final File avatarFile, - final Integer expirationTimer, - final Boolean isAnnouncementGroup - ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { - var group = getGroupForUpdating(groupId); - - if (group instanceof GroupInfoV2) { - try { - return updateGroupV2((GroupInfoV2) group, - name, - description, - members, - removeMembers, - admins, - removeAdmins, - resetGroupLink, - groupLinkState, - addMemberPermission, - editDetailsPermission, - avatarFile, - expirationTimer, - isAnnouncementGroup); - } catch (ConflictException e) { - // Detected conflicting update, refreshing group and trying again - group = getGroup(groupId, true); - return updateGroupV2((GroupInfoV2) group, - name, - description, - members, - removeMembers, - admins, - removeAdmins, - resetGroupLink, - groupLinkState, - addMemberPermission, - editDetailsPermission, - avatarFile, - expirationTimer, - isAnnouncementGroup); - } - } - - final var gv1 = (GroupInfoV1) group; - final var result = updateGroupV1(gv1, name, members, avatarFile); - if (expirationTimer != null) { - setExpirationTimer(gv1, expirationTimer); - } - return result; - } - - private SendGroupMessageResults updateGroupV1( - final GroupInfoV1 gv1, final String name, final Set members, final File avatarFile - ) throws IOException, AttachmentInvalidException { - updateGroupV1Details(gv1, name, members, avatarFile); - - account.getGroupStore().updateGroup(gv1); - - var messageBuilder = getGroupUpdateMessageBuilder(gv1); - return sendGroupMessage(messageBuilder, gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId())); - } - - private void updateGroupV1Details( - final GroupInfoV1 g, final String name, final Collection members, final File avatarFile - ) throws IOException { - if (name != null) { - g.name = name; - } - - if (members != null) { - final var newMemberAddresses = members.stream() - .filter(member -> !g.isMember(member)) - .map(this::resolveSignalServiceAddress) - .collect(Collectors.toList()); - final var newE164Members = new HashSet(); - for (var member : newMemberAddresses) { - if (!member.getNumber().isPresent()) { - continue; - } - newE164Members.add(member.getNumber().get()); - } - - final var registeredUsers = getRegisteredUsers(newE164Members); - if (registeredUsers.size() != newE164Members.size()) { - // Some of the new members are not registered on Signal - newE164Members.removeAll(registeredUsers.keySet()); - throw new IOException("Failed to add members " - + String.join(", ", newE164Members) - + " to group: Not registered on Signal"); - } - - g.addMembers(members); - } - - if (avatarFile != null) { - avatarStore.storeGroupAvatar(g.getGroupId(), - outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); - } - } - - private SendGroupMessageResults updateGroupV2( - final GroupInfoV2 group, - final String name, - final String description, - final Set members, - final Set removeMembers, - final Set admins, - final Set removeAdmins, - final boolean resetGroupLink, - final GroupLinkState groupLinkState, - final GroupPermission addMemberPermission, - final GroupPermission editDetailsPermission, - final File avatarFile, - final Integer expirationTimer, - final Boolean isAnnouncementGroup - ) throws IOException { - SendGroupMessageResults result = null; - if (group.isPendingMember(account.getSelfRecipientId())) { - var groupGroupChangePair = groupV2Helper.acceptInvite(group); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (members != null) { - final var newMembers = new HashSet<>(members); - newMembers.removeAll(group.getMembers()); - if (newMembers.size() > 0) { - var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - } - - if (removeMembers != null) { - var existingRemoveMembers = new HashSet<>(removeMembers); - existingRemoveMembers.retainAll(group.getMembers()); - existingRemoveMembers.remove(getSelfRecipientId());// self can be removed with sendQuitGroupMessage - if (existingRemoveMembers.size() > 0) { - var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - var pendingRemoveMembers = new HashSet<>(removeMembers); - pendingRemoveMembers.retainAll(group.getPendingMembers()); - if (pendingRemoveMembers.size() > 0) { - var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - } - - if (admins != null) { - final var newAdmins = new HashSet<>(admins); - newAdmins.retainAll(group.getMembers()); - newAdmins.removeAll(group.getAdminMembers()); - if (newAdmins.size() > 0) { - for (var admin : newAdmins) { - var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - groupGroupChangePair.second()); - } - } - } - - if (removeAdmins != null) { - final var existingRemoveAdmins = new HashSet<>(removeAdmins); - existingRemoveAdmins.retainAll(group.getAdminMembers()); - if (existingRemoveAdmins.size() > 0) { - for (var admin : existingRemoveAdmins) { - var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - groupGroupChangePair.second()); - } - } - } - - if (resetGroupLink) { - var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (groupLinkState != null) { - var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (addMemberPermission != null) { - var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (editDetailsPermission != null) { - var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (expirationTimer != null) { - var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (isAnnouncementGroup != null) { - var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup); - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - if (name != null || description != null || avatarFile != null) { - var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile); - if (avatarFile != null) { - avatarStore.storeGroupAvatar(group.getGroupId(), - outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); - } - result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); - } - - return result; - } - public Pair joinGroup( GroupInviteLinkUrl inviteLinkUrl ) throws IOException, GroupLinkNotActiveException { - final var groupJoinInfo = groupV2Helper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), - inviteLinkUrl.getPassword()); - final var groupChange = groupV2Helper.joinGroup(inviteLinkUrl.getGroupMasterKey(), - inviteLinkUrl.getPassword(), - groupJoinInfo); - final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(), - groupJoinInfo.getRevision() + 1, - groupChange.toByteArray()); - - if (group.getGroup() == null) { - // Only requested member, can't send update to group members - return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of())); - } - - final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange); - - return new Pair<>(group.getGroupId(), result); - } - - private SendGroupMessageResults sendUpdateGroupV2Message( - GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange - ) throws IOException { - final var selfRecipientId = account.getSelfRecipientId(); - final var members = group.getMembersIncludingPendingWithout(selfRecipientId); - group.setGroup(newDecryptedGroup, this::resolveRecipient); - members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId)); - account.getGroupStore().updateGroup(group); - - final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray()); - return sendGroupMessage(messageBuilder, members); + return groupHelper.joinGroup(inviteLinkUrl); } public SendMessageResults sendMessage( @@ -1134,100 +760,18 @@ public class Manager implements Closeable { } } - private SendGroupMessageResults sendGroupMessage( - final SignalServiceDataMessage.Builder messageBuilder, final Set members - ) throws IOException { - final var timestamp = System.currentTimeMillis(); - messageBuilder.withTimestamp(timestamp); - final var results = sendHelper.sendGroupMessage(messageBuilder.build(), members); - return new SendGroupMessageResults(timestamp, results); - } - - private static int currentTimeDays() { - return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); - } - - private GroupsV2AuthorizationString getGroupAuthForToday( - final GroupSecretParams groupSecretParams - ) throws IOException { - final var today = currentTimeDays(); - // Returns credentials for the next 7 days - final var credentials = dependencies.getGroupsV2Api().getCredentials(today); - // TODO cache credentials until they expire - var authCredentialResponse = credentials.get(today); - try { - return dependencies.getGroupsV2Api() - .getGroupsV2AuthorizationString(account.getUuid(), - today, - groupSecretParams, - authCredentialResponse); - } catch (VerificationFailedException e) { - throw new IOException(e); - } - } - SendGroupMessageResults sendGroupInfoMessage( GroupIdV1 groupId, SignalServiceAddress recipient ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException { - GroupInfoV1 g; - var group = getGroupForUpdating(groupId); - if (!(group instanceof GroupInfoV1)) { - throw new IOException("Received an invalid group request for a v2 group!"); - } - g = (GroupInfoV1) group; - final var recipientId = resolveRecipient(recipient); - if (!g.isMember(recipientId)) { - throw new NotAGroupMemberException(groupId, g.name); - } - - var messageBuilder = getGroupUpdateMessageBuilder(g); - - // Send group message only to the recipient who requested it - return sendGroupMessage(messageBuilder, Set.of(recipientId)); - } - - private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException { - var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE) - .withId(g.getGroupId().serialize()) - .withName(g.name) - .withMembers(g.getMembers() - .stream() - .map(this::resolveSignalServiceAddress) - .collect(Collectors.toList())); - - try { - final var attachment = createGroupAvatarAttachment(g.getGroupId()); - if (attachment.isPresent()) { - group.withAvatar(attachment.get()); - } - } catch (IOException e) { - throw new AttachmentInvalidException(g.getGroupId().toBase64(), e); - } - - return SignalServiceDataMessage.newBuilder() - .asGroupMessage(group.build()) - .withExpiration(g.getMessageExpirationTime()); - } - - private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) { - var group = SignalServiceGroupV2.newBuilder(g.getMasterKey()) - .withRevision(g.getGroup().getRevision()) - .withSignedGroupChange(signedGroupChange); - return SignalServiceDataMessage.newBuilder() - .asGroupMessage(group.build()) - .withExpiration(g.getMessageExpirationTime()); + return groupHelper.sendGroupInfoMessage(groupId, recipientId); } SendGroupMessageResults sendGroupInfoRequest( GroupIdV1 groupId, SignalServiceAddress recipient ) throws IOException { - var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize()); - - var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build()); - - // Send group info request message to the recipient who sent us a message with this groupId - return sendGroupMessage(messageBuilder, Set.of(resolveRecipient(recipient))); + final var recipientId = resolveRecipient(recipient); + return groupHelper.sendGroupInfoRequest(groupId, recipientId); } public void sendReadReceipt( @@ -1361,6 +905,7 @@ public class Manager implements Closeable { private void setContactBlocked(RecipientId recipientId, boolean blocked) { var contact = account.getContactStore().getContact(recipientId); final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); + // TODO cycle our profile key account.getContactStore().storeContact(recipientId, builder.withBlocked(blocked).build()); } @@ -1371,19 +916,10 @@ public class Manager implements Closeable { } group.setBlocked(blocked); + // TODO cycle our profile key account.getGroupStore().updateGroup(group); } - private void setExpirationTimer(RecipientId recipientId, int messageExpirationTimer) { - var contact = account.getContactStore().getContact(recipientId); - if (contact != null && contact.getMessageExpirationTime() == messageExpirationTimer) { - return; - } - final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); - account.getContactStore() - .storeContact(recipientId, builder.withMessageExpirationTime(messageExpirationTimer).build()); - } - /** * Change the expiration timer for a contact */ @@ -1400,20 +936,14 @@ public class Manager implements Closeable { } } - /** - * Change the expiration timer for a group - */ - private void setExpirationTimer( - GroupInfoV1 groupInfoV1, int messageExpirationTimer - ) throws NotAGroupMemberException, GroupNotFoundException, IOException { - groupInfoV1.messageExpirationTime = messageExpirationTimer; - account.getGroupStore().updateGroup(groupInfoV1); - sendExpirationTimerUpdate(groupInfoV1.getGroupId()); - } - - private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException { - final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); - sendHelper.sendAsGroupMessage(messageBuilder, groupId); + private void setExpirationTimer(RecipientId recipientId, int messageExpirationTimer) { + var contact = account.getContactStore().getContact(recipientId); + if (contact != null && contact.getMessageExpirationTime() == messageExpirationTimer) { + return; + } + final var builder = contact == null ? Contact.newBuilder() : Contact.newBuilder(contact); + account.getContactStore() + .storeContact(recipientId, builder.withMessageExpirationTime(messageExpirationTimer).build()); } /** @@ -1583,10 +1113,6 @@ public class Manager implements Closeable { sendTypingMessage(action.toSignalService(), recipients); } - private SignalServiceContent decryptMessage(SignalServiceEnvelope envelope) throws InvalidMetadataMessageException, ProtocolInvalidMessageException, ProtocolDuplicateMessageException, ProtocolLegacyMessageException, ProtocolInvalidKeyIdException, InvalidMetadataVersionException, ProtocolInvalidVersionException, ProtocolNoSessionException, ProtocolInvalidKeyException, SelfSendException, UnsupportedDataMessageException, ProtocolUntrustedIdentityException, InvalidMessageStructureException { - return dependencies.getCipher().decrypt(envelope); - } - private void handleEndSession(RecipientId recipientId) { account.getSessionStore().deleteAllSessions(recipientId); } @@ -1614,7 +1140,7 @@ public class Manager implements Closeable { if (groupInfo.getAvatar().isPresent()) { var avatar = groupInfo.getAvatar().get(); - downloadGroupAvatar(avatar, groupV1.getGroupId()); + downloadGroupAvatar(groupV1.getGroupId(), avatar); } if (groupInfo.getName().isPresent()) { @@ -1658,7 +1184,7 @@ public class Manager implements Closeable { final var groupContext = message.getGroupContext().get().getGroupV2().get(); final var groupMasterKey = groupContext.getMasterKey(); - getOrMigrateGroup(groupMasterKey, + groupHelper.getOrMigrateGroup(groupMasterKey, groupContext.getRevision(), groupContext.hasSignedGroupChange() ? groupContext.getSignedGroupChange() : null); } @@ -1743,65 +1269,6 @@ public class Manager implements Closeable { return actions; } - private GroupInfoV2 getOrMigrateGroup( - final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange - ) { - final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); - - var groupId = GroupUtils.getGroupIdV2(groupSecretParams); - var groupInfo = getGroup(groupId); - final GroupInfoV2 groupInfoV2; - if (groupInfo instanceof GroupInfoV1) { - // Received a v2 group message for a v1 group, we need to locally migrate the group - account.getGroupStore().deleteGroupV1(((GroupInfoV1) groupInfo).getGroupId()); - groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); - logger.info("Locally migrated group {} to group v2, id: {}", - groupInfo.getGroupId().toBase64(), - groupInfoV2.getGroupId().toBase64()); - } else if (groupInfo instanceof GroupInfoV2) { - groupInfoV2 = (GroupInfoV2) groupInfo; - } else { - groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); - } - - if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) { - DecryptedGroup group = null; - if (signedGroupChange != null - && groupInfoV2.getGroup() != null - && groupInfoV2.getGroup().getRevision() + 1 == revision) { - group = groupV2Helper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), - signedGroupChange, - groupMasterKey); - } - if (group == null) { - group = groupV2Helper.getDecryptedGroup(groupSecretParams); - } - if (group != null) { - storeProfileKeysFromMembers(group); - final var avatar = group.getAvatar(); - if (avatar != null && !avatar.isEmpty()) { - downloadGroupAvatar(groupId, groupSecretParams, avatar); - } - } - groupInfoV2.setGroup(group, this::resolveRecipient); - account.getGroupStore().updateGroup(groupInfoV2); - } - - return groupInfoV2; - } - - private void storeProfileKeysFromMembers(final DecryptedGroup group) { - for (var member : group.getMembersList()) { - final var uuid = UuidUtil.parseOrThrow(member.getUuid().toByteArray()); - final var recipientId = account.getRecipientStore().resolveRecipient(uuid); - try { - account.getProfileStore() - .storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray())); - } catch (InvalidInputException ignored) { - } - } - } - private void retryFailedReceivedMessages(ReceiveMessageHandler handler, boolean ignoreAttachments) { Set queuedActions = new HashSet<>(); for (var cachedMessage : account.getMessageCache().getCachedMessages()) { @@ -1824,7 +1291,7 @@ public class Manager implements Closeable { List actions = null; if (!envelope.isReceipt()) { try { - content = decryptMessage(envelope); + content = dependencies.getCipher().decrypt(envelope); } catch (ProtocolUntrustedIdentityException e) { if (!envelope.hasSource()) { final var identifier = e.getSender(); @@ -1915,7 +1382,7 @@ public class Manager implements Closeable { } if (!envelope.isReceipt()) { try { - content = decryptMessage(envelope); + content = dependencies.getCipher().decrypt(envelope); } catch (Exception e) { exception = e; } @@ -2157,7 +1624,7 @@ public class Manager implements Closeable { } if (g.getAvatar().isPresent()) { - downloadGroupAvatar(g.getAvatar().get(), syncGroup.getGroupId()); + downloadGroupAvatar(syncGroup.getGroupId(), g.getAvatar().get()); } syncGroup.archived = g.isArchived(); account.getGroupStore().updateGroup(syncGroup); @@ -2333,7 +1800,7 @@ public class Manager implements Closeable { } } - private void downloadGroupAvatar(SignalServiceAttachment avatar, GroupId groupId) { + private void downloadGroupAvatar(GroupIdV1 groupId, SignalServiceAttachment avatar) { try { avatarStore.storeGroupAvatar(groupId, outputStream -> retrieveAttachment(avatar, outputStream)); } catch (IOException e) { @@ -2341,15 +1808,6 @@ public class Manager implements Closeable { } } - private void downloadGroupAvatar(GroupId groupId, GroupSecretParams groupSecretParams, String cdnKey) { - try { - avatarStore.storeGroupAvatar(groupId, - outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream)); - } catch (IOException e) { - logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage()); - } - } - private void downloadProfileAvatar( SignalServiceAddress address, String avatarPath, ProfileKey profileKey ) { @@ -2392,29 +1850,6 @@ public class Manager implements Closeable { } } - private void retrieveGroupV2Avatar( - GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream - ) throws IOException { - var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams); - - var tmpFile = IOUtils.createTempFile(); - try (InputStream input = dependencies.getMessageReceiver() - .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { - var encryptedData = IOUtils.readFully(input); - - var decryptedData = groupOperations.decryptAvatar(encryptedData); - outputStream.write(decryptedData); - } finally { - try { - Files.delete(tmpFile.toPath()); - } catch (IOException e) { - logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}", - tmpFile, - e.getMessage()); - } - } - } - private void retrieveProfileAvatar( String avatarPath, ProfileKey profileKey, OutputStream outputStream ) throws IOException { @@ -2490,7 +1925,7 @@ public class Manager implements Closeable { .stream() .map(this::resolveSignalServiceAddress) .collect(Collectors.toList()), - createGroupAvatarAttachment(groupInfo.getGroupId()), + groupHelper.createGroupAvatarAttachment(groupInfo.getGroupId()), groupInfo.isMember(account.getSelfRecipientId()), Optional.of(groupInfo.messageExpirationTime), Optional.fromNullable(groupInfo.color), @@ -2639,17 +2074,7 @@ public class Manager implements Closeable { } public GroupInfo getGroup(GroupId groupId) { - return getGroup(groupId, false); - } - - public GroupInfo getGroup(GroupId groupId, boolean forceUpdate) { - final var group = account.getGroupStore().getGroup(groupId); - if (group instanceof GroupInfoV2 && (forceUpdate || ((GroupInfoV2) group).getGroup() == null)) { - final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(((GroupInfoV2) group).getMasterKey()); - ((GroupInfoV2) group).setGroup(groupV2Helper.getDecryptedGroup(groupSecretParams), this::resolveRecipient); - account.getGroupStore().updateGroup(group); - } - return group; + return groupHelper.getGroup(groupId); } public List getIdentities() { diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java new file mode 100644 index 00000000..0b9cc950 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -0,0 +1,628 @@ +package org.asamk.signal.manager.helper; + +import org.asamk.signal.manager.AttachmentInvalidException; +import org.asamk.signal.manager.AvatarStore; +import org.asamk.signal.manager.SignalDependencies; +import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.config.ServiceConfig; +import org.asamk.signal.manager.groups.GroupId; +import org.asamk.signal.manager.groups.GroupIdV1; +import org.asamk.signal.manager.groups.GroupIdV2; +import org.asamk.signal.manager.groups.GroupInviteLinkUrl; +import org.asamk.signal.manager.groups.GroupLinkState; +import org.asamk.signal.manager.groups.GroupNotFoundException; +import org.asamk.signal.manager.groups.GroupPermission; +import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.groups.LastGroupAdminException; +import org.asamk.signal.manager.groups.NotAGroupMemberException; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.groups.GroupInfo; +import org.asamk.signal.manager.storage.groups.GroupInfoV1; +import org.asamk.signal.manager.storage.groups.GroupInfoV2; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.recipients.RecipientResolver; +import org.asamk.signal.manager.util.AttachmentUtils; +import org.asamk.signal.manager.util.IOUtils; +import org.signal.storageservice.protos.groups.GroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.signal.zkgroup.groups.GroupSecretParams; +import org.signal.zkgroup.profiles.ProfileKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.api.push.exceptions.ConflictException; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class GroupHelper { + + private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class); + + private final SignalAccount account; + private final SignalDependencies dependencies; + private final SendHelper sendHelper; + private final GroupV2Helper groupV2Helper; + private final AvatarStore avatarStore; + private final SignalServiceAddressResolver addressResolver; + private final RecipientResolver recipientResolver; + + public GroupHelper( + final SignalAccount account, + final SignalDependencies dependencies, + final SendHelper sendHelper, + final GroupV2Helper groupV2Helper, + final AvatarStore avatarStore, + final SignalServiceAddressResolver addressResolver, + final RecipientResolver recipientResolver + ) { + this.account = account; + this.dependencies = dependencies; + this.sendHelper = sendHelper; + this.groupV2Helper = groupV2Helper; + this.avatarStore = avatarStore; + this.addressResolver = addressResolver; + this.recipientResolver = recipientResolver; + } + + public GroupInfo getGroup(GroupId groupId) { + return getGroup(groupId, false); + } + + public Optional createGroupAvatarAttachment(GroupIdV1 groupId) throws IOException { + final var streamDetails = avatarStore.retrieveGroupAvatar(groupId); + if (streamDetails == null) { + return Optional.absent(); + } + + return Optional.of(AttachmentUtils.createAttachment(streamDetails, Optional.absent())); + } + + public GroupInfoV2 getOrMigrateGroup( + final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange + ) { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + + var groupId = GroupUtils.getGroupIdV2(groupSecretParams); + var groupInfo = getGroup(groupId); + final GroupInfoV2 groupInfoV2; + if (groupInfo instanceof GroupInfoV1) { + // Received a v2 group message for a v1 group, we need to locally migrate the group + account.getGroupStore().deleteGroupV1(((GroupInfoV1) groupInfo).getGroupId()); + groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); + logger.info("Locally migrated group {} to group v2, id: {}", + groupInfo.getGroupId().toBase64(), + groupInfoV2.getGroupId().toBase64()); + } else if (groupInfo instanceof GroupInfoV2) { + groupInfoV2 = (GroupInfoV2) groupInfo; + } else { + groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey); + } + + if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < revision) { + DecryptedGroup group = null; + if (signedGroupChange != null + && groupInfoV2.getGroup() != null + && groupInfoV2.getGroup().getRevision() + 1 == revision) { + group = groupV2Helper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), + signedGroupChange, + groupMasterKey); + } + if (group == null) { + group = groupV2Helper.getDecryptedGroup(groupSecretParams); + } + if (group != null) { + storeProfileKeysFromMembers(group); + final var avatar = group.getAvatar(); + if (avatar != null && !avatar.isEmpty()) { + downloadGroupAvatar(groupId, groupSecretParams, avatar); + } + } + groupInfoV2.setGroup(group, recipientResolver); + account.getGroupStore().updateGroup(groupInfoV2); + } + + return groupInfoV2; + } + + public Pair createGroup( + String name, Set members, File avatarFile + ) throws IOException, AttachmentInvalidException { + final var selfRecipientId = account.getSelfRecipientId(); + if (members != null && members.contains(selfRecipientId)) { + members = new HashSet<>(members); + members.remove(selfRecipientId); + } + + var gv2Pair = groupV2Helper.createGroup(name == null ? "" : name, + members == null ? Set.of() : members, + avatarFile); + + if (gv2Pair == null) { + // Failed to create v2 group, creating v1 group instead + var gv1 = new GroupInfoV1(GroupIdV1.createRandom()); + gv1.addMembers(List.of(selfRecipientId)); + final var result = updateGroupV1(gv1, name, members, avatarFile); + return new Pair<>(gv1.getGroupId(), result); + } + + final var gv2 = gv2Pair.first(); + final var decryptedGroup = gv2Pair.second(); + + gv2.setGroup(decryptedGroup, recipientResolver); + if (avatarFile != null) { + avatarStore.storeGroupAvatar(gv2.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + + account.getGroupStore().updateGroup(gv2); + + final var messageBuilder = getGroupUpdateMessageBuilder(gv2, null); + + final var result = sendGroupMessage(messageBuilder, gv2.getMembersIncludingPendingWithout(selfRecipientId)); + return new Pair<>(gv2.getGroupId(), result); + } + + public SendGroupMessageResults updateGroup( + final GroupId groupId, + final String name, + final String description, + final Set members, + final Set removeMembers, + final Set admins, + final Set removeAdmins, + final boolean resetGroupLink, + final GroupLinkState groupLinkState, + final GroupPermission addMemberPermission, + final GroupPermission editDetailsPermission, + final File avatarFile, + final Integer expirationTimer, + final Boolean isAnnouncementGroup + ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { + var group = getGroupForUpdating(groupId); + + if (group instanceof GroupInfoV2) { + try { + return updateGroupV2((GroupInfoV2) group, + name, + description, + members, + removeMembers, + admins, + removeAdmins, + resetGroupLink, + groupLinkState, + addMemberPermission, + editDetailsPermission, + avatarFile, + expirationTimer, + isAnnouncementGroup); + } catch (ConflictException e) { + // Detected conflicting update, refreshing group and trying again + group = getGroup(groupId, true); + return updateGroupV2((GroupInfoV2) group, + name, + description, + members, + removeMembers, + admins, + removeAdmins, + resetGroupLink, + groupLinkState, + addMemberPermission, + editDetailsPermission, + avatarFile, + expirationTimer, + isAnnouncementGroup); + } + } + + final var gv1 = (GroupInfoV1) group; + final var result = updateGroupV1(gv1, name, members, avatarFile); + if (expirationTimer != null) { + setExpirationTimer(gv1, expirationTimer); + } + return result; + } + + public Pair joinGroup( + GroupInviteLinkUrl inviteLinkUrl + ) throws IOException, GroupLinkNotActiveException { + final var groupJoinInfo = groupV2Helper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(), + inviteLinkUrl.getPassword()); + final var groupChange = groupV2Helper.joinGroup(inviteLinkUrl.getGroupMasterKey(), + inviteLinkUrl.getPassword(), + groupJoinInfo); + final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(), + groupJoinInfo.getRevision() + 1, + groupChange.toByteArray()); + + if (group.getGroup() == null) { + // Only requested member, can't send update to group members + return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of())); + } + + final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange); + + return new Pair<>(group.getGroupId(), result); + } + + public SendGroupMessageResults quitGroup( + final GroupId groupId, final Set newAdmins + ) throws IOException, LastGroupAdminException, NotAGroupMemberException, GroupNotFoundException { + var group = getGroupForUpdating(groupId); + if (group instanceof GroupInfoV1) { + return quitGroupV1((GroupInfoV1) group); + } + + try { + return quitGroupV2((GroupInfoV2) group, newAdmins); + } catch (ConflictException e) { + // Detected conflicting update, refreshing group and trying again + group = getGroup(groupId, true); + return quitGroupV2((GroupInfoV2) group, newAdmins); + } + } + + public SendGroupMessageResults sendGroupInfoRequest( + GroupIdV1 groupId, RecipientId recipientId + ) throws IOException { + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.REQUEST_INFO).withId(groupId.serialize()); + + var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group.build()); + + // Send group info request message to the recipient who sent us a message with this groupId + return sendGroupMessage(messageBuilder, Set.of(recipientId)); + } + + public SendGroupMessageResults sendGroupInfoMessage( + GroupIdV1 groupId, RecipientId recipientId + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException { + GroupInfoV1 g; + var group = getGroupForUpdating(groupId); + if (!(group instanceof GroupInfoV1)) { + throw new IOException("Received an invalid group request for a v2 group!"); + } + g = (GroupInfoV1) group; + + if (!g.isMember(recipientId)) { + throw new NotAGroupMemberException(groupId, g.name); + } + + var messageBuilder = getGroupUpdateMessageBuilder(g); + + // Send group message only to the recipient who requested it + return sendGroupMessage(messageBuilder, Set.of(recipientId)); + } + + private GroupInfo getGroup(GroupId groupId, boolean forceUpdate) { + final var group = account.getGroupStore().getGroup(groupId); + if (group instanceof GroupInfoV2 && (forceUpdate || ((GroupInfoV2) group).getGroup() == null)) { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(((GroupInfoV2) group).getMasterKey()); + ((GroupInfoV2) group).setGroup(groupV2Helper.getDecryptedGroup(groupSecretParams), recipientResolver); + account.getGroupStore().updateGroup(group); + } + return group; + } + + private void downloadGroupAvatar(GroupIdV2 groupId, GroupSecretParams groupSecretParams, String cdnKey) { + try { + avatarStore.storeGroupAvatar(groupId, + outputStream -> retrieveGroupV2Avatar(groupSecretParams, cdnKey, outputStream)); + } catch (IOException e) { + logger.warn("Failed to download avatar for group {}, ignoring: {}", groupId.toBase64(), e.getMessage()); + } + } + + private void retrieveGroupV2Avatar( + GroupSecretParams groupSecretParams, String cdnKey, OutputStream outputStream + ) throws IOException { + var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams); + + var tmpFile = IOUtils.createTempFile(); + try (InputStream input = dependencies.getMessageReceiver() + .retrieveGroupsV2ProfileAvatar(cdnKey, tmpFile, ServiceConfig.AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE)) { + var encryptedData = IOUtils.readFully(input); + + var decryptedData = groupOperations.decryptAvatar(encryptedData); + outputStream.write(decryptedData); + } finally { + try { + Files.delete(tmpFile.toPath()); + } catch (IOException e) { + logger.warn("Failed to delete received group avatar temp file “{}”, ignoring: {}", + tmpFile, + e.getMessage()); + } + } + } + + private void storeProfileKeysFromMembers(final DecryptedGroup group) { + for (var member : group.getMembersList()) { + final var uuid = UuidUtil.parseOrThrow(member.getUuid().toByteArray()); + final var recipientId = account.getRecipientStore().resolveRecipient(uuid); + try { + account.getProfileStore() + .storeProfileKey(recipientId, new ProfileKey(member.getProfileKey().toByteArray())); + } catch (InvalidInputException ignored) { + } + } + } + + private GroupInfo getGroupForUpdating(GroupId groupId) throws GroupNotFoundException, NotAGroupMemberException { + var g = getGroup(groupId); + if (g == null) { + throw new GroupNotFoundException(groupId); + } + if (!g.isMember(account.getSelfRecipientId()) && !g.isPendingMember(account.getSelfRecipientId())) { + throw new NotAGroupMemberException(groupId, g.getTitle()); + } + return g; + } + + private SendGroupMessageResults updateGroupV1( + final GroupInfoV1 gv1, final String name, final Set members, final File avatarFile + ) throws IOException, AttachmentInvalidException { + updateGroupV1Details(gv1, name, members, avatarFile); + + account.getGroupStore().updateGroup(gv1); + + var messageBuilder = getGroupUpdateMessageBuilder(gv1); + return sendGroupMessage(messageBuilder, gv1.getMembersIncludingPendingWithout(account.getSelfRecipientId())); + } + + private void updateGroupV1Details( + final GroupInfoV1 g, final String name, final Collection members, final File avatarFile + ) throws IOException { + if (name != null) { + g.name = name; + } + + if (members != null) { + g.addMembers(members); + } + + if (avatarFile != null) { + avatarStore.storeGroupAvatar(g.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + } + + /** + * Change the expiration timer for a group + */ + private void setExpirationTimer( + GroupInfoV1 groupInfoV1, int messageExpirationTimer + ) throws NotAGroupMemberException, GroupNotFoundException, IOException { + groupInfoV1.messageExpirationTime = messageExpirationTimer; + account.getGroupStore().updateGroup(groupInfoV1); + sendExpirationTimerUpdate(groupInfoV1.getGroupId()); + } + + private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException { + final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); + sendHelper.sendAsGroupMessage(messageBuilder, groupId); + } + + private SendGroupMessageResults updateGroupV2( + final GroupInfoV2 group, + final String name, + final String description, + final Set members, + final Set removeMembers, + final Set admins, + final Set removeAdmins, + final boolean resetGroupLink, + final GroupLinkState groupLinkState, + final GroupPermission addMemberPermission, + final GroupPermission editDetailsPermission, + final File avatarFile, + final Integer expirationTimer, + final Boolean isAnnouncementGroup + ) throws IOException { + SendGroupMessageResults result = null; + if (group.isPendingMember(account.getSelfRecipientId())) { + var groupGroupChangePair = groupV2Helper.acceptInvite(group); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (members != null) { + final var newMembers = new HashSet<>(members); + newMembers.removeAll(group.getMembers()); + if (newMembers.size() > 0) { + var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + } + + if (removeMembers != null) { + var existingRemoveMembers = new HashSet<>(removeMembers); + existingRemoveMembers.retainAll(group.getMembers()); + existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage + if (existingRemoveMembers.size() > 0) { + var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + var pendingRemoveMembers = new HashSet<>(removeMembers); + pendingRemoveMembers.retainAll(group.getPendingMembers()); + if (pendingRemoveMembers.size() > 0) { + var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + } + + if (admins != null) { + final var newAdmins = new HashSet<>(admins); + newAdmins.retainAll(group.getMembers()); + newAdmins.removeAll(group.getAdminMembers()); + if (newAdmins.size() > 0) { + for (var admin : newAdmins) { + var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true); + result = sendUpdateGroupV2Message(group, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + } + } + + if (removeAdmins != null) { + final var existingRemoveAdmins = new HashSet<>(removeAdmins); + existingRemoveAdmins.retainAll(group.getAdminMembers()); + if (existingRemoveAdmins.size() > 0) { + for (var admin : existingRemoveAdmins) { + var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false); + result = sendUpdateGroupV2Message(group, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + } + } + + if (resetGroupLink) { + var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (groupLinkState != null) { + var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (addMemberPermission != null) { + var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (editDetailsPermission != null) { + var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (expirationTimer != null) { + var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (isAnnouncementGroup != null) { + var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + if (name != null || description != null || avatarFile != null) { + var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile); + if (avatarFile != null) { + avatarStore.storeGroupAvatar(group.getGroupId(), + outputStream -> IOUtils.copyFileToStream(avatarFile, outputStream)); + } + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); + } + + return result; + } + + private SendGroupMessageResults quitGroupV1(final GroupInfoV1 groupInfoV1) throws IOException { + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT) + .withId(groupInfoV1.getGroupId().serialize()) + .build(); + + var messageBuilder = SignalServiceDataMessage.newBuilder().asGroupMessage(group); + groupInfoV1.removeMember(account.getSelfRecipientId()); + account.getGroupStore().updateGroup(groupInfoV1); + return sendGroupMessage(messageBuilder, + groupInfoV1.getMembersIncludingPendingWithout(account.getSelfRecipientId())); + } + + private SendGroupMessageResults quitGroupV2( + final GroupInfoV2 groupInfoV2, final Set newAdmins + ) throws LastGroupAdminException, IOException { + final var currentAdmins = groupInfoV2.getAdminMembers(); + newAdmins.removeAll(currentAdmins); + newAdmins.retainAll(groupInfoV2.getMembers()); + if (currentAdmins.contains(account.getSelfRecipientId()) + && currentAdmins.size() == 1 + && groupInfoV2.getMembers().size() > 1 + && newAdmins.size() == 0) { + // Last admin can't leave the group, unless she's also the last member + throw new LastGroupAdminException(groupInfoV2.getGroupId(), groupInfoV2.getTitle()); + } + final var groupGroupChangePair = groupV2Helper.leaveGroup(groupInfoV2, newAdmins); + groupInfoV2.setGroup(groupGroupChangePair.first(), recipientResolver); + account.getGroupStore().updateGroup(groupInfoV2); + + var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupGroupChangePair.second().toByteArray()); + return sendGroupMessage(messageBuilder, + groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId())); + } + + private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException { + var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE) + .withId(g.getGroupId().serialize()) + .withName(g.name) + .withMembers(g.getMembers() + .stream() + .map(addressResolver::resolveSignalServiceAddress) + .collect(Collectors.toList())); + + try { + final var attachment = createGroupAvatarAttachment(g.getGroupId()); + if (attachment.isPresent()) { + group.withAvatar(attachment.get()); + } + } catch (IOException e) { + throw new AttachmentInvalidException(g.getGroupId().toBase64(), e); + } + + return SignalServiceDataMessage.newBuilder() + .asGroupMessage(group.build()) + .withExpiration(g.getMessageExpirationTime()); + } + + private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) { + var group = SignalServiceGroupV2.newBuilder(g.getMasterKey()) + .withRevision(g.getGroup().getRevision()) + .withSignedGroupChange(signedGroupChange); + return SignalServiceDataMessage.newBuilder() + .asGroupMessage(group.build()) + .withExpiration(g.getMessageExpirationTime()); + } + + private SendGroupMessageResults sendUpdateGroupV2Message( + GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange + ) throws IOException { + final var selfRecipientId = account.getSelfRecipientId(); + final var members = group.getMembersIncludingPendingWithout(selfRecipientId); + group.setGroup(newDecryptedGroup, recipientResolver); + members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId)); + account.getGroupStore().updateGroup(group); + + final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.toByteArray()); + return sendGroupMessage(messageBuilder, members); + } + + private SendGroupMessageResults sendGroupMessage( + final SignalServiceDataMessage.Builder messageBuilder, final Set members + ) throws IOException { + final var timestamp = System.currentTimeMillis(); + messageBuilder.withTimestamp(timestamp); + final var results = sendHelper.sendGroupMessage(messageBuilder.build(), members); + return new SendGroupMessageResults(timestamp, results); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java index e161673e..19240cef 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java @@ -43,6 +43,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.Set; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; public class GroupV2Helper { @@ -59,8 +60,6 @@ public class GroupV2Helper { private final GroupsV2Api groupsV2Api; - private final GroupAuthorizationProvider groupAuthorizationProvider; - private final SignalServiceAddressResolver addressResolver; public GroupV2Helper( @@ -69,7 +68,6 @@ public class GroupV2Helper { final SelfRecipientIdProvider selfRecipientIdProvider, final GroupsV2Operations groupsV2Operations, final GroupsV2Api groupsV2Api, - final GroupAuthorizationProvider groupAuthorizationProvider, final SignalServiceAddressResolver addressResolver ) { this.profileKeyCredentialProvider = profileKeyCredentialProvider; @@ -77,14 +75,12 @@ public class GroupV2Helper { this.selfRecipientIdProvider = selfRecipientIdProvider; this.groupsV2Operations = groupsV2Operations; this.groupsV2Api = groupsV2Api; - this.groupAuthorizationProvider = groupAuthorizationProvider; this.addressResolver = addressResolver; } public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) { try { - final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday( - groupSecretParams); + final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams); return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString); } catch (IOException | VerificationFailedException | InvalidGroupStateException e) { logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage()); @@ -99,7 +95,7 @@ public class GroupV2Helper { return groupsV2Api.getGroupJoinInfo(groupSecretParams, Optional.fromNullable(password).transform(GroupLinkPassword::serialize), - groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams)); + getGroupAuthForToday(groupSecretParams)); } public Pair createGroup( @@ -116,7 +112,7 @@ public class GroupV2Helper { final GroupsV2AuthorizationString groupAuthForToday; final DecryptedGroup decryptedGroup; try { - groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams); + groupAuthForToday = getGroupAuthForToday(groupSecretParams); groupsV2Api.putNewGroup(newGroup, groupAuthForToday); decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday); } catch (IOException | VerificationFailedException | InvalidGroupStateException e) { @@ -214,7 +210,7 @@ public class GroupV2Helper { final var avatarBytes = readAvatarBytes(avatarFile); var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes, groupSecretParams, - groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams)); + getGroupAuthForToday(groupSecretParams)); change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey)); } @@ -487,7 +483,7 @@ public class GroupV2Helper { } var signedGroupChange = groupsV2Api.patchGroup(changeActions, - groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams), + getGroupAuthForToday(groupSecretParams), Optional.absent()); return new Pair<>(decryptedGroupState, signedGroupChange); @@ -503,7 +499,7 @@ public class GroupV2Helper { final var changeActions = change.setRevision(nextRevision).build(); return groupsV2Api.patchGroup(changeActions, - groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams), + getGroupAuthForToday(groupSecretParams), Optional.fromNullable(password).transform(GroupLinkPassword::serialize)); } @@ -534,4 +530,26 @@ public class GroupV2Helper { return null; } + + private static int currentTimeDays() { + return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()); + } + + private GroupsV2AuthorizationString getGroupAuthForToday( + final GroupSecretParams groupSecretParams + ) throws IOException { + final var today = currentTimeDays(); + // Returns credentials for the next 7 days + final var credentials = groupsV2Api.getCredentials(today); + // TODO cache credentials until they expire + var authCredentialResponse = credentials.get(today); + final var uuid = addressResolver.resolveSignalServiceAddress(this.selfRecipientIdProvider.getSelfRecipientId()) + .getUuid() + .get(); + try { + return groupsV2Api.getGroupsV2AuthorizationString(uuid, today, groupSecretParams, authCredentialResponse); + } catch (VerificationFailedException e) { + throw new IOException(e); + } + } } -- 2.50.1