]> nmode's Git Repositories - signal-cli/commitdiff
Implement accepting and declining group invitations
authorAsamK <asamk@gmx.de>
Mon, 21 Dec 2020 14:20:18 +0000 (15:20 +0100)
committerAsamK <asamk@gmx.de>
Mon, 21 Dec 2020 14:23:50 +0000 (15:23 +0100)
CHANGELOG.md
man/signal-cli.1.adoc
src/main/java/org/asamk/signal/manager/Manager.java
src/main/java/org/asamk/signal/manager/helper/GroupHelper.java
src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java
src/main/java/org/asamk/signal/storage/groups/GroupInfo.java

index 9494ae8ab978e676bc9aec0f10c3b766d9642abb..4972cbeabb07d82f956a3107be40eccfff7159a4 100644 (file)
@@ -1,6 +1,10 @@
 # Changelog
 
 ## [Unreleased]
+### Added
+- Accept group invitation with `updateGroup -g GROUP_ID`
+- Decline group invitation with `quitGroup -g GROUP_ID`
+
 ### Fixed
 - Include group ids for v2 groups in json output
 
index 0bef0afcf0fc07c84629d0f7e68a0bc90b3d39a3..e9b525931c77f4c246854598327aa5414542d80e 100644 (file)
@@ -181,6 +181,7 @@ Output received messages in json format, one object per line.
 === updateGroup
 
 Create or update a group.
+If the user is a pending member, this command will accept the group invitation.
 
 *-g* GROUP, *--group* GROUP::
 Specify the recipient group ID in base64 encoding.
@@ -198,6 +199,7 @@ Specify one or more members to add to the group.
 === quitGroup
 
 Send a quit group message to all group members and remove self from member list.
+If the user is a pending member, this command will decline the group invitation.
 
 *-g* GROUP, *--group* GROUP::
 Specify the recipient group ID in base64 encoding.
@@ -235,7 +237,7 @@ Specify the safety number of the key, only use this option if you have verified
 
 Update the name and avatar image visible by message recipients for the current users.
 The profile is stored encrypted on the Signal servers.
-The decryption key is sent with every outgoing messages (excluding group messages).
+The decryption key is sent with every outgoing messages to contacts.
 
 *--name*::
 New name visible by message recipients.
index 26f5abbc20ee29d0873614e8fc42ce6a6015b8a1..24bc0ebd8a04be61b2eb1c60e8f15815cbcc45e0 100644 (file)
@@ -322,6 +322,8 @@ public class Manager implements Closeable {
             contact.profileKey = null;
             account.getProfileStore().storeProfileKey(contact.getAddress(), profileKey);
         }
+        // Ensure our profile key is stored in profile store
+        account.getProfileStore().storeProfileKey(getSelfAddress(), account.getProfileKey());
     }
 
     public void checkAccountState() throws IOException {
@@ -705,6 +707,17 @@ public class Manager implements Closeable {
         return g;
     }
 
+    private GroupInfo getGroupForUpdating(byte[] groupId) throws GroupNotFoundException, NotAGroupMemberException {
+        GroupInfo g = account.getGroupStore().getGroup(groupId);
+        if (g == null) {
+            throw new GroupNotFoundException(groupId);
+        }
+        if (!g.isMember(account.getSelfAddress()) && !g.isPendingMember(account.getSelfAddress())) {
+            throw new NotAGroupMemberException(groupId, g.getTitle());
+        }
+        return g;
+    }
+
     public List<GroupInfo> getGroups() {
         return account.getGroupStore().getGroups();
     }
@@ -749,7 +762,7 @@ public class Manager implements Closeable {
 
         SignalServiceDataMessage.Builder messageBuilder;
 
-        final GroupInfo g = getGroupForSending(groupId);
+        final GroupInfo g = getGroupForUpdating(groupId);
         if (g instanceof GroupInfoV1) {
             GroupInfoV1 groupInfoV1 = (GroupInfoV1) g;
             SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
@@ -788,31 +801,39 @@ public class Manager implements Closeable {
                 g = gv2;
             }
         } else {
-            GroupInfo group = getGroupForSending(groupId);
+            GroupInfo group = getGroupForUpdating(groupId);
             if (group instanceof GroupInfoV2) {
-                Pair<DecryptedGroup, GroupChange> groupGroupChangePair = null;
+                final GroupInfoV2 groupInfoV2 = (GroupInfoV2) group;
+
+                Pair<Long, List<SendMessageResult>> result = null;
+                if (groupInfoV2.isPendingMember(getSelfAddress())) {
+                    Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.acceptInvite(groupInfoV2);
+                    result = sendUpdateGroupMessage(groupInfoV2,
+                            groupGroupChangePair.first(),
+                            groupGroupChangePair.second());
+                }
+
                 if (members != null) {
                     final Set<SignalServiceAddress> newMembers = new HashSet<>(members);
                     newMembers.removeAll(group.getMembers());
                     if (newMembers.size() > 0) {
-                        groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, newMembers);
+                        Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2,
+                                newMembers);
+                        result = sendUpdateGroupMessage(groupInfoV2,
+                                groupGroupChangePair.first(),
+                                groupGroupChangePair.second());
                     }
                 }
-                if (groupGroupChangePair == null || name != null || avatarFile != null) {
-                    if (groupGroupChangePair != null) {
-                        ((GroupInfoV2) group).setGroup(groupGroupChangePair.first());
-                        messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group,
-                                groupGroupChangePair.second().toByteArray());
-                        sendMessage(messageBuilder, group.getMembersWithout(account.getSelfAddress()));
-                    }
-
-                    groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, name, avatarFile);
+                if (result == null || name != null || avatarFile != null) {
+                    Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2,
+                            name,
+                            avatarFile);
+                    result = sendUpdateGroupMessage(groupInfoV2,
+                            groupGroupChangePair.first(),
+                            groupGroupChangePair.second());
                 }
 
-                ((GroupInfoV2) group).setGroup(groupGroupChangePair.first());
-                messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group,
-                        groupGroupChangePair.second().toByteArray());
-                g = group;
+                return new Pair<>(group.groupId, result.second());
             } else {
                 GroupInfoV1 gv1 = (GroupInfoV1) group;
                 updateGroupV1(gv1, name, members, avatarFile);
@@ -824,10 +845,20 @@ public class Manager implements Closeable {
         account.getGroupStore().updateGroup(g);
 
         final Pair<Long, List<SendMessageResult>> result = sendMessage(messageBuilder,
-                g.getMembersWithout(account.getSelfAddress()));
+                g.getMembersIncludingPendingWithout(account.getSelfAddress()));
         return new Pair<>(g.groupId, result.second());
     }
 
+    private Pair<Long, List<SendMessageResult>> sendUpdateGroupMessage(
+            GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
+    ) throws IOException {
+        group.setGroup(newDecryptedGroup);
+        final SignalServiceDataMessage.Builder messageBuilder = getGroupUpdateMessageBuilder(group,
+                groupChange.toByteArray());
+        account.getGroupStore().updateGroup(group);
+        return sendMessage(messageBuilder, group.getMembersIncludingPendingWithout(account.getSelfAddress()));
+    }
+
     private void updateGroupV1(
             final GroupInfoV1 g,
             final String name,
@@ -1582,6 +1613,9 @@ public class Manager implements Closeable {
                             group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(),
                                     groupContext.getSignedGroupChange(),
                                     groupMasterKey);
+                            if (group != null) {
+                                storeProfileKeysFromMembers(group);
+                            }
                         }
                         if (group == null) {
                             group = getDecryptedGroup(groupSecretParams);
@@ -1678,15 +1712,7 @@ public class Manager implements Closeable {
         try {
             final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
             DecryptedGroup group = groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
-            for (DecryptedMember member : group.getMembersList()) {
-                final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(
-                        member.getUuid().toByteArray()), null));
-                try {
-                    account.getProfileStore()
-                            .storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray()));
-                } catch (InvalidInputException ignored) {
-                }
-            }
+            storeProfileKeysFromMembers(group);
             return group;
         } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
             System.err.println("Failed to retrieve Group V2 info, ignoring ...");
@@ -1694,6 +1720,18 @@ public class Manager implements Closeable {
         }
     }
 
+    private void storeProfileKeysFromMembers(final DecryptedGroup group) {
+        for (DecryptedMember member : group.getMembersList()) {
+            final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(
+                    member.getUuid().toByteArray()), null));
+            try {
+                account.getProfileStore()
+                        .storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray()));
+            } catch (InvalidInputException ignored) {
+            }
+        }
+    }
+
     private void retryFailedReceivedMessages(
             ReceiveMessageHandler handler, boolean ignoreAttachments
     ) {
index 7c0339c92c187a9f0c79558c9afb01c879e30157..1f7e69e3552b9d2ae7e55d589e95d4b359f9883f 100644 (file)
@@ -118,24 +118,7 @@ public class GroupHelper {
             return null;
         }
 
-        final int noUuidCapability = members.stream()
-                .filter(address -> !address.getUuid().isPresent())
-                .collect(Collectors.toUnmodifiableSet())
-                .size();
-        if (noUuidCapability > 0) {
-            System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID.");
-            return null;
-        }
-
-        final int noGv2Capability = members.stream()
-                .map(profileProvider::getProfile)
-                .filter(profile -> !profile.getCapabilities().gv2)
-                .collect(Collectors.toUnmodifiableSet())
-                .size();
-        if (noGv2Capability > 0) {
-            System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2.");
-            return null;
-        }
+        if (!areMembersValid(members)) return null;
 
         GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
                 Optional.fromNullable(profileKeyCredential));
@@ -154,6 +137,29 @@ public class GroupHelper {
                 0);
     }
 
+    private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
+        final int noUuidCapability = members.stream()
+                .filter(address -> !address.getUuid().isPresent())
+                .collect(Collectors.toUnmodifiableSet())
+                .size();
+        if (noUuidCapability > 0) {
+            System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID.");
+            return false;
+        }
+
+        final int noGv2Capability = members.stream()
+                .map(profileProvider::getProfile)
+                .filter(profile -> profile != null && !profile.getCapabilities().gv2)
+                .collect(Collectors.toUnmodifiableSet())
+                .size();
+        if (noGv2Capability > 0) {
+            System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2.");
+            return false;
+        }
+
+        return true;
+    }
+
     public Pair<DecryptedGroup, GroupChange> updateGroupV2(
             GroupInfoV2 groupInfoV2, String name, String avatarFile
     ) throws IOException {
@@ -186,6 +192,8 @@ public class GroupHelper {
         final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
         GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
 
+        if (!areMembersValid(newMembers)) return null;
+
         Set<GroupCandidate> candidates = newMembers.stream()
                 .map(member -> new GroupCandidate(member.getUuid().get(),
                         Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
@@ -215,6 +223,27 @@ public class GroupHelper {
         }
     }
 
+    public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
+        final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
+        final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+
+        final SignalServiceAddress selfAddress = this.selfAddressProvider.getSelfAddress();
+        final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
+                selfAddress);
+        if (profileKeyCredential == null) {
+            throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
+        }
+
+        final GroupChange.Actions.Builder change = groupOperations.createAcceptInviteChange(profileKeyCredential);
+
+        final Optional<UUID> uuid = selfAddress.getUuid();
+        if (uuid.isPresent()) {
+            change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
+        }
+
+        return commitChange(groupInfoV2, change);
+    }
+
     public Pair<DecryptedGroup, GroupChange> revokeInvites(
             GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
     ) throws IOException {
index ba1980d48edc49160cc891726103d37ce38589e5..c81e2ff7f9c95e108ea5e250990c0d70d42f1093 100644 (file)
@@ -4,8 +4,6 @@ import org.signal.zkgroup.profiles.ProfileKey;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
 import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
-import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
-import org.whispersystems.signalservice.api.crypto.ProfileCipher;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
 import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
@@ -15,7 +13,6 @@ import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
 import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
 import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
 import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
-import org.whispersystems.util.Base64;
 
 import java.io.IOException;
 import java.util.Arrays;
@@ -87,17 +84,6 @@ public final class ProfileHelper {
         }
     }
 
-    public String decryptName(
-            ProfileKey profileKey, String encryptedName
-    ) throws InvalidCiphertextException, IOException {
-        if (encryptedName == null) {
-            return null;
-        }
-
-        ProfileCipher profileCipher = new ProfileCipher(profileKey);
-        return new String(profileCipher.decryptName(Base64.decode(encryptedName)));
-    }
-
     private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
             SignalServiceAddress address,
             Optional<ProfileKey> profileKey,
index d24a2694251ef85215559c24d544f85ab7d4ff13..4cd410b88a4d84e5d89bfcc3f81eed3a87908a78 100644 (file)
@@ -5,8 +5,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
 
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
-import java.util.HashSet;
 import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 public abstract class GroupInfo {
 
@@ -44,18 +45,29 @@ public abstract class GroupInfo {
 
     @JsonIgnore
     public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
-        Set<SignalServiceAddress> members = new HashSet<>();
+        return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
+    }
+
+    @JsonIgnore
+    public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
+        return Stream.concat(getMembers().stream(), getPendingMembers().stream())
+                .filter(member -> !member.matches(address))
+                .collect(Collectors.toSet());
+    }
+
+    @JsonIgnore
+    public boolean isMember(SignalServiceAddress address) {
         for (SignalServiceAddress member : getMembers()) {
-            if (!member.matches(address)) {
-                members.add(member);
+            if (member.matches(address)) {
+                return true;
             }
         }
-        return members;
+        return false;
     }
 
     @JsonIgnore
-    public boolean isMember(SignalServiceAddress address) {
-        for (SignalServiceAddress member : getMembers()) {
+    public boolean isPendingMember(SignalServiceAddress address) {
+        for (SignalServiceAddress member : getPendingMembers()) {
             if (member.matches(address)) {
                 return true;
             }