From: AsamK Date: Mon, 21 Dec 2020 14:20:18 +0000 (+0100) Subject: Implement accepting and declining group invitations X-Git-Tag: v0.7.1~5 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/17608ce5220142b979f777cbd1512416043f52bb?ds=sidebyside Implement accepting and declining group invitations --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 9494ae8a..4972cbea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 0bef0afc..e9b52593 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -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. diff --git a/src/main/java/org/asamk/signal/manager/Manager.java b/src/main/java/org/asamk/signal/manager/Manager.java index 26f5abbc..24bc0ebd 100644 --- a/src/main/java/org/asamk/signal/manager/Manager.java +++ b/src/main/java/org/asamk/signal/manager/Manager.java @@ -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 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 groupGroupChangePair = null; + final GroupInfoV2 groupInfoV2 = (GroupInfoV2) group; + + Pair> result = null; + if (groupInfoV2.isPendingMember(getSelfAddress())) { + Pair groupGroupChangePair = groupHelper.acceptInvite(groupInfoV2); + result = sendUpdateGroupMessage(groupInfoV2, + groupGroupChangePair.first(), + groupGroupChangePair.second()); + } + if (members != null) { final Set newMembers = new HashSet<>(members); newMembers.removeAll(group.getMembers()); if (newMembers.size() > 0) { - groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, newMembers); + Pair 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 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> result = sendMessage(messageBuilder, - g.getMembersWithout(account.getSelfAddress())); + g.getMembersIncludingPendingWithout(account.getSelfAddress())); return new Pair<>(g.groupId, result.second()); } + private Pair> 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 ) { 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 7c0339c9..1f7e69e3 100644 --- a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -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 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 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 candidates = newMembers.stream() .map(member -> new GroupCandidate(member.getUuid().get(), Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member)))) @@ -215,6 +223,27 @@ public class GroupHelper { } } + public Pair 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 = selfAddress.getUuid(); + if (uuid.isPresent()) { + change.setSourceUuid(UuidUtil.toByteString(uuid.get())); + } + + return commitChange(groupInfoV2, change); + } + public Pair revokeInvites( GroupInfoV2 groupInfoV2, Set pendingMembers ) throws IOException { diff --git a/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java b/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java index ba1980d4..c81e2ff7 100644 --- a/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java +++ b/src/main/java/org/asamk/signal/manager/helper/ProfileHelper.java @@ -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 getPipeRetrievalFuture( SignalServiceAddress address, Optional profileKey, diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java index d24a2694..4cd410b8 100644 --- a/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java @@ -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 getMembersWithout(SignalServiceAddress address) { - Set members = new HashSet<>(); + return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet()); + } + + @JsonIgnore + public Set 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; }