Implement accepting and declining group invitations

This commit is contained in:
AsamK 2020-12-21 15:20:18 +01:00
parent c49b05cd75
commit 17608ce522
6 changed files with 139 additions and 68 deletions

View file

@ -1,6 +1,10 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Added
- Accept group invitation with `updateGroup -g GROUP_ID`
- Decline group invitation with `quitGroup -g GROUP_ID`
### Fixed ### Fixed
- Include group ids for v2 groups in json output - Include group ids for v2 groups in json output

View file

@ -181,6 +181,7 @@ Output received messages in json format, one object per line.
=== updateGroup === updateGroup
Create or update a group. Create or update a group.
If the user is a pending member, this command will accept the group invitation.
*-g* GROUP, *--group* GROUP:: *-g* GROUP, *--group* GROUP::
Specify the recipient group ID in base64 encoding. Specify the recipient group ID in base64 encoding.
@ -198,6 +199,7 @@ Specify one or more members to add to the group.
=== quitGroup === quitGroup
Send a quit group message to all group members and remove self from member list. 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:: *-g* GROUP, *--group* GROUP::
Specify the recipient group ID in base64 encoding. 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. Update the name and avatar image visible by message recipients for the current users.
The profile is stored encrypted on the Signal servers. 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*:: *--name*::
New name visible by message recipients. New name visible by message recipients.

View file

@ -322,6 +322,8 @@ public class Manager implements Closeable {
contact.profileKey = null; contact.profileKey = null;
account.getProfileStore().storeProfileKey(contact.getAddress(), profileKey); 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 { public void checkAccountState() throws IOException {
@ -705,6 +707,17 @@ public class Manager implements Closeable {
return g; 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() { public List<GroupInfo> getGroups() {
return account.getGroupStore().getGroups(); return account.getGroupStore().getGroups();
} }
@ -749,7 +762,7 @@ public class Manager implements Closeable {
SignalServiceDataMessage.Builder messageBuilder; SignalServiceDataMessage.Builder messageBuilder;
final GroupInfo g = getGroupForSending(groupId); final GroupInfo g = getGroupForUpdating(groupId);
if (g instanceof GroupInfoV1) { if (g instanceof GroupInfoV1) {
GroupInfoV1 groupInfoV1 = (GroupInfoV1) g; GroupInfoV1 groupInfoV1 = (GroupInfoV1) g;
SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT) SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
@ -788,31 +801,39 @@ public class Manager implements Closeable {
g = gv2; g = gv2;
} }
} else { } else {
GroupInfo group = getGroupForSending(groupId); GroupInfo group = getGroupForUpdating(groupId);
if (group instanceof GroupInfoV2) { 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) { if (members != null) {
final Set<SignalServiceAddress> newMembers = new HashSet<>(members); final Set<SignalServiceAddress> newMembers = new HashSet<>(members);
newMembers.removeAll(group.getMembers()); newMembers.removeAll(group.getMembers());
if (newMembers.size() > 0) { 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 (result == null || name != null || avatarFile != null) {
if (groupGroupChangePair != null) { Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2,
((GroupInfoV2) group).setGroup(groupGroupChangePair.first()); name,
messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group, avatarFile);
groupGroupChangePair.second().toByteArray()); result = sendUpdateGroupMessage(groupInfoV2,
sendMessage(messageBuilder, group.getMembersWithout(account.getSelfAddress())); groupGroupChangePair.first(),
groupGroupChangePair.second());
} }
groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, name, avatarFile); return new Pair<>(group.groupId, result.second());
}
((GroupInfoV2) group).setGroup(groupGroupChangePair.first());
messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group,
groupGroupChangePair.second().toByteArray());
g = group;
} else { } else {
GroupInfoV1 gv1 = (GroupInfoV1) group; GroupInfoV1 gv1 = (GroupInfoV1) group;
updateGroupV1(gv1, name, members, avatarFile); updateGroupV1(gv1, name, members, avatarFile);
@ -824,10 +845,20 @@ public class Manager implements Closeable {
account.getGroupStore().updateGroup(g); account.getGroupStore().updateGroup(g);
final Pair<Long, List<SendMessageResult>> result = sendMessage(messageBuilder, final Pair<Long, List<SendMessageResult>> result = sendMessage(messageBuilder,
g.getMembersWithout(account.getSelfAddress())); g.getMembersIncludingPendingWithout(account.getSelfAddress()));
return new Pair<>(g.groupId, result.second()); 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( private void updateGroupV1(
final GroupInfoV1 g, final GroupInfoV1 g,
final String name, final String name,
@ -1582,6 +1613,9 @@ public class Manager implements Closeable {
group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(), group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(),
groupContext.getSignedGroupChange(), groupContext.getSignedGroupChange(),
groupMasterKey); groupMasterKey);
if (group != null) {
storeProfileKeysFromMembers(group);
}
} }
if (group == null) { if (group == null) {
group = getDecryptedGroup(groupSecretParams); group = getDecryptedGroup(groupSecretParams);
@ -1678,6 +1712,15 @@ public class Manager implements Closeable {
try { try {
final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams); final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
DecryptedGroup group = groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString); 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 void storeProfileKeysFromMembers(final DecryptedGroup group) {
for (DecryptedMember member : group.getMembersList()) { for (DecryptedMember member : group.getMembersList()) {
final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow( final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(
member.getUuid().toByteArray()), null)); member.getUuid().toByteArray()), null));
@ -1687,11 +1730,6 @@ public class Manager implements Closeable {
} catch (InvalidInputException ignored) { } catch (InvalidInputException ignored) {
} }
} }
return group;
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
System.err.println("Failed to retrieve Group V2 info, ignoring ...");
return null;
}
} }
private void retryFailedReceivedMessages( private void retryFailedReceivedMessages(

View file

@ -118,24 +118,7 @@ public class GroupHelper {
return null; return null;
} }
final int noUuidCapability = members.stream() if (!areMembersValid(members)) return null;
.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;
}
GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(), GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
Optional.fromNullable(profileKeyCredential)); Optional.fromNullable(profileKeyCredential));
@ -154,6 +137,29 @@ public class GroupHelper {
0); 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( public Pair<DecryptedGroup, GroupChange> updateGroupV2(
GroupInfoV2 groupInfoV2, String name, String avatarFile GroupInfoV2 groupInfoV2, String name, String avatarFile
) throws IOException { ) throws IOException {
@ -186,6 +192,8 @@ public class GroupHelper {
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
if (!areMembersValid(newMembers)) return null;
Set<GroupCandidate> candidates = newMembers.stream() Set<GroupCandidate> candidates = newMembers.stream()
.map(member -> new GroupCandidate(member.getUuid().get(), .map(member -> new GroupCandidate(member.getUuid().get(),
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member)))) 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( public Pair<DecryptedGroup, GroupChange> revokeInvites(
GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
) throws IOException { ) throws IOException {

View file

@ -4,8 +4,6 @@ import org.signal.zkgroup.profiles.ProfileKey;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessagePipe; import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; 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.UnidentifiedAccess;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; 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.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture; import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture; import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import org.whispersystems.util.Base64;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays; 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( private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
SignalServiceAddress address, SignalServiceAddress address,
Optional<ProfileKey> profileKey, Optional<ProfileKey> profileKey,

View file

@ -5,8 +5,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public abstract class GroupInfo { public abstract class GroupInfo {
@ -44,13 +45,14 @@ public abstract class GroupInfo {
@JsonIgnore @JsonIgnore
public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) { public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
Set<SignalServiceAddress> members = new HashSet<>(); return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
for (SignalServiceAddress member : getMembers()) {
if (!member.matches(address)) {
members.add(member);
} }
}
return members; @JsonIgnore
public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
return Stream.concat(getMembers().stream(), getPendingMembers().stream())
.filter(member -> !member.matches(address))
.collect(Collectors.toSet());
} }
@JsonIgnore @JsonIgnore
@ -62,4 +64,14 @@ public abstract class GroupInfo {
} }
return false; return false;
} }
@JsonIgnore
public boolean isPendingMember(SignalServiceAddress address) {
for (SignalServiceAddress member : getPendingMembers()) {
if (member.matches(address)) {
return true;
}
}
return false;
}
} }