From 445e8592c491438e3ea863c2382ea42b1161fe84 Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 21 Dec 2020 20:03:19 +0100 Subject: [PATCH] Implement join group via invitation link --- CHANGELOG.md | 1 + man/signal-cli.1.adoc | 7 + .../org/asamk/signal/commands/Commands.java | 1 + .../signal/commands/JoinGroupCommand.java | 84 ++++++++++++ .../signal/commands/SendReactionCommand.java | 3 +- .../org/asamk/signal/manager/GroupUtils.java | 4 +- .../org/asamk/signal/manager/Manager.java | 124 +++++++++++------- .../signal/manager/helper/GroupHelper.java | 67 +++++++++- .../org/asamk/signal/util/ErrorUtils.java | 4 +- 9 files changed, 239 insertions(+), 56 deletions(-) create mode 100644 src/main/java/org/asamk/signal/commands/JoinGroupCommand.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4972cbea..a5094ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index e9b52593..b5c22167 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -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. diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 183b40a0..85e7af32 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -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 index 00000000..62b996cf --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/JoinGroupCommand.java @@ -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> 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; + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java index 6e5f24bb..000a1349 100644 --- a/src/main/java/org/asamk/signal/commands/SendReactionCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendReactionCommand.java @@ -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; diff --git a/src/main/java/org/asamk/signal/manager/GroupUtils.java b/src/main/java/org/asamk/signal/manager/GroupUtils.java index 0d192002..a0e95c7a 100644 --- a/src/main/java/org/asamk/signal/manager/GroupUtils.java +++ b/src/main/java/org/asamk/signal/manager/GroupUtils.java @@ -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) { diff --git a/src/main/java/org/asamk/signal/manager/Manager.java b/src/main/java/org/asamk/signal/manager/Manager.java index 6db40566..ab063d1b 100644 --- a/src/main/java/org/asamk/signal/manager/Manager.java +++ b/src/main/java/org/asamk/signal/manager/Manager.java @@ -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> joinGroup( + GroupInviteLinkUrl inviteLinkUrl + ) throws IOException, GroupLinkNotActiveException { + return sendJoinGroupMessage(inviteLinkUrl); + } + + private Pair> 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> result = sendUpdateGroupMessage(group, group.getGroup(), groupChange); + + return new Pair<>(group.groupId, result.second()); + } + private Pair> 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) { diff --git a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index 1f7e69e3..d66831bb 100644 --- a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -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 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 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 ) { diff --git a/src/main/java/org/asamk/signal/util/ErrorUtils.java b/src/main/java/org/asamk/signal/util/ErrorUtils.java index 8e27dd90..8e65d440 100644 --- a/src/main/java/org/asamk/signal/util/ErrorUtils.java +++ b/src/main/java/org/asamk/signal/util/ErrorUtils.java @@ -22,7 +22,9 @@ public class ErrorUtils { } public static int handleTimestampAndSendMessageResults(long timestamp, List results) { - System.out.println(timestamp); + if (timestamp != 0) { + System.out.println(timestamp); + } List errors = getErrorMessagesFromSendMessageResults(results); return handleSendMessageResultErrors(errors); } -- 2.50.1