]> nmode's Git Repositories - signal-cli/commitdiff
Implement join group via invitation link
authorAsamK <asamk@gmx.de>
Mon, 21 Dec 2020 19:03:19 +0000 (20:03 +0100)
committerAsamK <asamk@gmx.de>
Mon, 21 Dec 2020 20:21:40 +0000 (21:21 +0100)
CHANGELOG.md
man/signal-cli.1.adoc
src/main/java/org/asamk/signal/commands/Commands.java
src/main/java/org/asamk/signal/commands/JoinGroupCommand.java [new file with mode: 0644]
src/main/java/org/asamk/signal/commands/SendReactionCommand.java
src/main/java/org/asamk/signal/manager/GroupUtils.java
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/util/ErrorUtils.java

index 4972cbeabb07d82f956a3107be40eccfff7159a4..a5094ac280d96f60c06a76ad1bcb9cbe913af44a 100644 (file)
@@ -4,6 +4,7 @@
 ### Added
 - Accept group invitation with `updateGroup -g GROUP_ID`
 - Decline group invitation with `quitGroup -g GROUP_ID`
+- Join group via invitation link `joinGroup --uri https://signal.group/#...`
 
 ### Fixed
 - Include group ids for v2 groups in json output
index e9b525931c77f4c246854598327aa5414542d80e..b5c221679cc63dd19810fc6ad6ee5d021338bee9 100644 (file)
@@ -178,6 +178,13 @@ Don’t download attachments of received messages.
 *--json*::
 Output received messages in json format, one object per line.
 
+=== joinGroup
+
+Join a group via an invitation link.
+
+*--uri*::
+The invitation link URI (starts with `https://signal.group/#`)
+
 === updateGroup
 
 Create or update a group.
index 183b40a00a16a07d23e852f76fd77dc25c7a3159..85e7af32665095f5cd2d6fdab7b91eb052ee8777 100644 (file)
@@ -16,6 +16,7 @@ public class Commands {
         addCommand("listDevices", new ListDevicesCommand());
         addCommand("listGroups", new ListGroupsCommand());
         addCommand("listIdentities", new ListIdentitiesCommand());
+        addCommand("joinGroup", new JoinGroupCommand());
         addCommand("quitGroup", new QuitGroupCommand());
         addCommand("receive", new ReceiveCommand());
         addCommand("register", new RegisterCommand());
diff --git a/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java
new file mode 100644 (file)
index 0000000..62b996c
--- /dev/null
@@ -0,0 +1,84 @@
+package org.asamk.signal.commands;
+
+import net.sourceforge.argparse4j.inf.Namespace;
+import net.sourceforge.argparse4j.inf.Subparser;
+
+import org.asamk.Signal;
+import org.asamk.signal.manager.GroupInviteLinkUrl;
+import org.asamk.signal.manager.Manager;
+import org.freedesktop.dbus.exceptions.DBusExecutionException;
+import org.whispersystems.libsignal.util.Pair;
+import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
+import org.whispersystems.signalservice.api.messages.SendMessageResult;
+import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException;
+import org.whispersystems.util.Base64;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.asamk.signal.util.ErrorUtils.handleAssertionError;
+import static org.asamk.signal.util.ErrorUtils.handleIOException;
+import static org.asamk.signal.util.ErrorUtils.handleTimestampAndSendMessageResults;
+
+public class JoinGroupCommand implements LocalCommand {
+
+    @Override
+    public void attachToSubparser(final Subparser subparser) {
+        subparser.addArgument("--uri").required(true).help("Specify the uri with the group invitation link.");
+    }
+
+    @Override
+    public int handleCommand(final Namespace ns, final Manager m) {
+        if (!m.isRegistered()) {
+            System.err.println("User is not registered.");
+            return 1;
+        }
+
+        final GroupInviteLinkUrl linkUrl;
+        String uri = ns.getString("uri");
+        try {
+            linkUrl = GroupInviteLinkUrl.fromUri(uri);
+        } catch (GroupInviteLinkUrl.InvalidGroupLinkException e) {
+            System.err.println("Group link is invalid: " + e.getMessage());
+            return 2;
+        } catch (GroupInviteLinkUrl.UnknownGroupLinkVersionException e) {
+            System.err.println("Group link was created with an incompatible version: " + e.getMessage());
+            return 2;
+        }
+
+        if (linkUrl == null) {
+            System.err.println("Link is not a signal group invitation link");
+            return 2;
+        }
+
+        try {
+            final Pair<byte[], List<SendMessageResult>> results = m.joinGroup(linkUrl);
+            byte[] newGroupId = results.first();
+            if (!m.getGroup(newGroupId).isMember(m.getSelfAddress())) {
+                System.out.println("Requested to join group \"" + Base64.encodeBytes(newGroupId) + "\"");
+            } else {
+                System.out.println("Joined group \"" + Base64.encodeBytes(newGroupId) + "\"");
+            }
+            return handleTimestampAndSendMessageResults(0, results.second());
+        } catch (AssertionError e) {
+            handleAssertionError(e);
+            return 1;
+        } catch (GroupPatchNotAcceptedException e) {
+            System.err.println("Failed to join group, maybe already a member");
+            return 1;
+        } catch (IOException e) {
+            e.printStackTrace();
+            handleIOException(e);
+            return 1;
+        } catch (Signal.Error.AttachmentInvalid e) {
+            System.err.println("Failed to add avatar attachment for group\": " + e.getMessage());
+            return 1;
+        } catch (DBusExecutionException e) {
+            System.err.println("Failed to send message: " + e.getMessage());
+            return 1;
+        } catch (GroupLinkNotActiveException e) {
+            System.err.println("Group link is not valid: " + e.getMessage());
+            return 2;
+        }
+    }
+}
index 6e5f24bbb9a6b971eed610bb8cb028039a3723d6..000a1349517e77a883d1a93f41b5fa2a7db397fa 100644 (file)
@@ -74,8 +74,7 @@ public class SendReactionCommand implements LocalCommand {
                         targetTimestamp,
                         ns.getList("recipient"));
             }
-            handleTimestampAndSendMessageResults(results.first(), results.second());
-            return 0;
+            return handleTimestampAndSendMessageResults(results.first(), results.second());
         } catch (IOException e) {
             handleIOException(e);
             return 3;
index 0d192002e70975ea6778d25c7fbd40256ac2be00..a0e95c7aff21086d05aa7ae5f0f7e879f11f5226 100644 (file)
@@ -35,9 +35,9 @@ public class GroupUtils {
         return groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
     }
 
-    public static GroupMasterKey deriveV2MigrationMasterKey(byte[] groupId) {
+    public static GroupMasterKey deriveV2MigrationMasterKey(byte[] groupIdV1) {
         try {
-            return new GroupMasterKey(new HKDFv3().deriveSecrets(groupId,
+            return new GroupMasterKey(new HKDFv3().deriveSecrets(groupIdV1,
                     "GV2 Migration".getBytes(),
                     GroupMasterKey.SIZE));
         } catch (InvalidInputException e) {
index 6db40566236400133f9f65a18c10e66883a86960..ab063d1b89aa4fc161d991782e5a6925ae9ceb84 100644 (file)
@@ -45,6 +45,7 @@ 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.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
 import org.signal.storageservice.protos.groups.local.DecryptedMember;
 import org.signal.zkgroup.InvalidInputException;
 import org.signal.zkgroup.VerificationFailedException;
@@ -78,10 +79,10 @@ import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
 import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
 import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
+import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
-import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
 import org.whispersystems.signalservice.api.messages.SendMessageResult;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@@ -849,6 +850,34 @@ public class Manager implements Closeable {
         return new Pair<>(g.groupId, result.second());
     }
 
+    public Pair<byte[], List<SendMessageResult>> joinGroup(
+            GroupInviteLinkUrl inviteLinkUrl
+    ) throws IOException, GroupLinkNotActiveException {
+        return sendJoinGroupMessage(inviteLinkUrl);
+    }
+
+    private Pair<byte[], List<SendMessageResult>> sendJoinGroupMessage(
+            GroupInviteLinkUrl inviteLinkUrl
+    ) throws IOException, GroupLinkNotActiveException {
+        final DecryptedGroupJoinInfo groupJoinInfo = groupHelper.getDecryptedGroupJoinInfo(inviteLinkUrl.getGroupMasterKey(),
+                inviteLinkUrl.getPassword());
+        final GroupChange groupChange = groupHelper.joinGroup(inviteLinkUrl.getGroupMasterKey(),
+                inviteLinkUrl.getPassword(),
+                groupJoinInfo);
+        final GroupInfoV2 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.groupId, List.of());
+        }
+
+        final Pair<Long, List<SendMessageResult>> result = sendUpdateGroupMessage(group, group.getGroup(), groupChange);
+
+        return new Pair<>(group.groupId, result.second());
+    }
+
     private Pair<Long, List<SendMessageResult>> sendUpdateGroupMessage(
             GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
     ) throws IOException {
@@ -1584,48 +1613,12 @@ public class Manager implements Closeable {
                 final SignalServiceGroupV2 groupContext = message.getGroupContext().get().getGroupV2().get();
                 final GroupMasterKey groupMasterKey = groupContext.getMasterKey();
 
-                final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
-
-                byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
-                GroupInfo groupInfo = account.getGroupStore().getGroupByV2Id(groupId);
-                if (groupInfo instanceof GroupInfoV1) {
-                    // Received a v2 group message for a v2 group, we need to locally migrate the group
-                    account.getGroupStore().deleteGroup(groupInfo.groupId);
-                    GroupInfoV2 groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
-                    groupInfoV2.setGroup(getDecryptedGroup(groupSecretParams));
-                    account.getGroupStore().updateGroup(groupInfoV2);
-                    System.err.println("Locally migrated group "
-                            + Base64.encodeBytes(groupInfo.groupId)
-                            + " to group v2, id: "
-                            + Base64.encodeBytes(groupInfoV2.groupId)
-                            + " !!!");
-                } else if (groupInfo == null || groupInfo instanceof GroupInfoV2) {
-                    GroupInfoV2 groupInfoV2 = groupInfo == null
-                            ? new GroupInfoV2(groupId, groupMasterKey)
-                            : (GroupInfoV2) groupInfo;
-
-                    if (groupInfoV2.getGroup() == null
-                            || groupInfoV2.getGroup().getRevision() < groupContext.getRevision()) {
-                        DecryptedGroup group = null;
-                        if (groupContext.hasSignedGroupChange()
-                                && groupInfoV2.getGroup() != null
-                                && groupInfoV2.getGroup().getRevision() + 1 == groupContext.getRevision()) {
-                            group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(),
-                                    groupContext.getSignedGroupChange(),
-                                    groupMasterKey);
-                            if (group != null) {
-                                storeProfileKeysFromMembers(group);
-                            }
-                        }
-                        if (group == null) {
-                            group = getDecryptedGroup(groupSecretParams);
-                        }
-                        groupInfoV2.setGroup(group);
-                        account.getGroupStore().updateGroup(groupInfoV2);
-                    }
-                }
+                getOrMigrateGroup(groupMasterKey,
+                        groupContext.getRevision(),
+                        groupContext.hasSignedGroupChange() ? groupContext.getSignedGroupChange() : null);
             }
         }
+
         final SignalServiceAddress conversationPartnerAddress = isSync ? destination : source;
         if (message.isEndSession()) {
             handleEndSession(conversationPartnerAddress);
@@ -1708,16 +1701,47 @@ public class Manager implements Closeable {
         return actions;
     }
 
-    private DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
-        try {
-            final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
-            DecryptedGroup group = groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
-            storeProfileKeysFromMembers(group);
-            return group;
-        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
-            System.err.println("Failed to retrieve Group V2 info, ignoring ...");
-            return null;
+    private GroupInfoV2 getOrMigrateGroup(
+            final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange
+    ) {
+        final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+
+        byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
+        GroupInfo groupInfo = account.getGroupStore().getGroupByV2Id(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().deleteGroup(groupInfo.groupId);
+            groupInfoV2 = new GroupInfoV2(groupId, groupMasterKey);
+            System.err.println("Locally migrated group "
+                    + Base64.encodeBytes(groupInfo.groupId)
+                    + " to group v2, id: "
+                    + Base64.encodeBytes(groupInfoV2.groupId)
+                    + " !!!");
+        } 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 = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), signedGroupChange, groupMasterKey);
+            }
+            if (group == null) {
+                group = groupHelper.getDecryptedGroup(groupSecretParams);
+            }
+            if (group != null) {
+                storeProfileKeysFromMembers(group);
+            }
+            groupInfoV2.setGroup(group);
+            account.getGroupStore().updateGroup(groupInfoV2);
+        }
+
+        return groupInfoV2;
     }
 
     private void storeProfileKeysFromMembers(final DecryptedGroup group) {
index 1f7e69e3552b9d2ae7e55d589e95d4b359f9883f..d66831bba0775fed65217319d97377500b2045f1 100644 (file)
@@ -2,12 +2,15 @@ package org.asamk.signal.manager.helper;
 
 import com.google.protobuf.InvalidProtocolBufferException;
 
+import org.asamk.signal.manager.GroupLinkPassword;
 import org.asamk.signal.storage.groups.GroupInfoV2;
 import org.asamk.signal.util.IOUtils;
+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.DecryptedGroup;
 import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
+import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
 import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
 import org.signal.zkgroup.InvalidInputException;
 import org.signal.zkgroup.VerificationFailedException;
@@ -19,6 +22,7 @@ import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
 import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
+import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
@@ -66,6 +70,27 @@ public class GroupHelper {
         this.groupAuthorizationProvider = groupAuthorizationProvider;
     }
 
+    public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
+        try {
+            final GroupsV2AuthorizationString groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
+                    groupSecretParams);
+            return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
+        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
+            System.err.println("Failed to retrieve Group V2 info, ignoring ...");
+            return null;
+        }
+    }
+
+    public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
+            GroupMasterKey groupMasterKey, GroupLinkPassword password
+    ) throws IOException, GroupLinkNotActiveException {
+        GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+
+        return groupsV2Api.getGroupJoinInfo(groupSecretParams,
+                Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
+                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
+    }
+
     public GroupInfoV2 createGroupV2(
             String name, Collection<SignalServiceAddress> members, String avatarFile
     ) throws IOException {
@@ -223,6 +248,32 @@ public class GroupHelper {
         }
     }
 
+    public GroupChange joinGroup(
+            GroupMasterKey groupMasterKey,
+            GroupLinkPassword groupLinkPassword,
+            DecryptedGroupJoinInfo decryptedGroupJoinInfo
+    ) throws IOException {
+        final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+        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");
+        }
+
+        boolean requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink()
+                == AccessControl.AccessRequired.ADMINISTRATOR;
+        GroupChange.Actions.Builder change = requestToJoin
+                ? groupOperations.createGroupJoinRequest(profileKeyCredential)
+                : groupOperations.createGroupJoinDirect(profileKeyCredential);
+
+        change.setSourceUuid(UuidUtil.toByteString(selfAddress.getUuid().get()));
+
+        return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
+    }
+
     public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
         final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
         final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
@@ -284,13 +335,27 @@ public class GroupHelper {
             throw new IOException(e);
         }
 
-        GroupChange signedGroupChange = groupsV2Api.patchGroup(change.build(),
+        GroupChange signedGroupChange = groupsV2Api.patchGroup(changeActions,
                 groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
                 Optional.absent());
 
         return new Pair<>(decryptedGroupState, signedGroupChange);
     }
 
+    private GroupChange commitChange(
+            GroupSecretParams groupSecretParams,
+            int currentRevision,
+            GroupChange.Actions.Builder change,
+            GroupLinkPassword password
+    ) throws IOException {
+        final int nextRevision = currentRevision + 1;
+        final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
+
+        return groupsV2Api.patchGroup(changeActions,
+                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
+                Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
+    }
+
     public DecryptedGroup getUpdatedDecryptedGroup(
             DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
     ) {
index 8e27dd909c69b512e5750b3838a6e015443c0620..8e65d440e894e8e6eb27279a789c8a706af3f0d3 100644 (file)
@@ -22,7 +22,9 @@ public class ErrorUtils {
     }
 
     public static int handleTimestampAndSendMessageResults(long timestamp, List<SendMessageResult> results) {
-        System.out.println(timestamp);
+        if (timestamp != 0) {
+            System.out.println(timestamp);
+        }
         List<String> errors = getErrorMessagesFromSendMessageResults(results);
         return handleSendMessageResultErrors(errors);
     }