Implement join group via invitation link

This commit is contained in:
AsamK 2020-12-21 20:03:19 +01:00
parent 9912da9546
commit 445e8592c4
9 changed files with 239 additions and 56 deletions

View file

@ -4,6 +4,7 @@
### Added ### Added
- Accept group invitation with `updateGroup -g GROUP_ID` - Accept group invitation with `updateGroup -g GROUP_ID`
- Decline group invitation with `quitGroup -g GROUP_ID` - Decline group invitation with `quitGroup -g GROUP_ID`
- Join group via invitation link `joinGroup --uri https://signal.group/#...`
### Fixed ### Fixed
- Include group ids for v2 groups in json output - Include group ids for v2 groups in json output

View file

@ -178,6 +178,13 @@ Dont download attachments of received messages.
*--json*:: *--json*::
Output received messages in json format, one object per line. 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 === updateGroup
Create or update a group. Create or update a group.

View file

@ -16,6 +16,7 @@ public class Commands {
addCommand("listDevices", new ListDevicesCommand()); addCommand("listDevices", new ListDevicesCommand());
addCommand("listGroups", new ListGroupsCommand()); addCommand("listGroups", new ListGroupsCommand());
addCommand("listIdentities", new ListIdentitiesCommand()); addCommand("listIdentities", new ListIdentitiesCommand());
addCommand("joinGroup", new JoinGroupCommand());
addCommand("quitGroup", new QuitGroupCommand()); addCommand("quitGroup", new QuitGroupCommand());
addCommand("receive", new ReceiveCommand()); addCommand("receive", new ReceiveCommand());
addCommand("register", new RegisterCommand()); addCommand("register", new RegisterCommand());

View file

@ -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;
}
}
}

View file

@ -74,8 +74,7 @@ public class SendReactionCommand implements LocalCommand {
targetTimestamp, targetTimestamp,
ns.getList("recipient")); ns.getList("recipient"));
} }
handleTimestampAndSendMessageResults(results.first(), results.second()); return handleTimestampAndSendMessageResults(results.first(), results.second());
return 0;
} catch (IOException e) { } catch (IOException e) {
handleIOException(e); handleIOException(e);
return 3; return 3;

View file

@ -35,9 +35,9 @@ public class GroupUtils {
return groupSecretParams.getPublicParams().getGroupIdentifier().serialize(); return groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
} }
public static GroupMasterKey deriveV2MigrationMasterKey(byte[] groupId) { public static GroupMasterKey deriveV2MigrationMasterKey(byte[] groupIdV1) {
try { try {
return new GroupMasterKey(new HKDFv3().deriveSecrets(groupId, return new GroupMasterKey(new HKDFv3().deriveSecrets(groupIdV1,
"GV2 Migration".getBytes(), "GV2 Migration".getBytes(),
GroupMasterKey.SIZE)); GroupMasterKey.SIZE));
} catch (InvalidInputException e) { } catch (InvalidInputException e) {

View file

@ -45,6 +45,7 @@ import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
import org.signal.libsignal.metadata.SelfSendException; import org.signal.libsignal.metadata.SelfSendException;
import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; 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.storageservice.protos.groups.local.DecryptedMember;
import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException; 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.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; 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.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; 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.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -849,6 +850,34 @@ public class Manager implements Closeable {
return new Pair<>(g.groupId, result.second()); 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( private Pair<Long, List<SendMessageResult>> sendUpdateGroupMessage(
GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
) throws IOException { ) throws IOException {
@ -1584,48 +1613,12 @@ public class Manager implements Closeable {
final SignalServiceGroupV2 groupContext = message.getGroupContext().get().getGroupV2().get(); final SignalServiceGroupV2 groupContext = message.getGroupContext().get().getGroupV2().get();
final GroupMasterKey groupMasterKey = groupContext.getMasterKey(); final GroupMasterKey groupMasterKey = groupContext.getMasterKey();
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); getOrMigrateGroup(groupMasterKey,
groupContext.getRevision(),
byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize(); groupContext.hasSignedGroupChange() ? groupContext.getSignedGroupChange() : null);
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);
}
}
} }
} }
final SignalServiceAddress conversationPartnerAddress = isSync ? destination : source; final SignalServiceAddress conversationPartnerAddress = isSync ? destination : source;
if (message.isEndSession()) { if (message.isEndSession()) {
handleEndSession(conversationPartnerAddress); handleEndSession(conversationPartnerAddress);
@ -1708,16 +1701,47 @@ public class Manager implements Closeable {
return actions; return actions;
} }
private DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) { private GroupInfoV2 getOrMigrateGroup(
try { final GroupMasterKey groupMasterKey, final int revision, final byte[] signedGroupChange
final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams); ) {
DecryptedGroup group = groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString); final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
storeProfileKeysFromMembers(group);
return group; byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) { GroupInfo groupInfo = account.getGroupStore().getGroupByV2Id(groupId);
System.err.println("Failed to retrieve Group V2 info, ignoring ..."); final GroupInfoV2 groupInfoV2;
return null; 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) { private void storeProfileKeysFromMembers(final DecryptedGroup group) {

View file

@ -2,12 +2,15 @@ package org.asamk.signal.manager.helper;
import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.InvalidProtocolBufferException;
import org.asamk.signal.manager.GroupLinkPassword;
import org.asamk.signal.storage.groups.GroupInfoV2; import org.asamk.signal.storage.groups.GroupInfoV2;
import org.asamk.signal.util.IOUtils; 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.GroupChange;
import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.protos.groups.Member;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; 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.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException; 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.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate; 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.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
@ -66,6 +70,27 @@ public class GroupHelper {
this.groupAuthorizationProvider = groupAuthorizationProvider; 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( public GroupInfoV2 createGroupV2(
String name, Collection<SignalServiceAddress> members, String avatarFile String name, Collection<SignalServiceAddress> members, String avatarFile
) throws IOException { ) 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 { public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
@ -284,13 +335,27 @@ public class GroupHelper {
throw new IOException(e); throw new IOException(e);
} }
GroupChange signedGroupChange = groupsV2Api.patchGroup(change.build(), GroupChange signedGroupChange = groupsV2Api.patchGroup(changeActions,
groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams), groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
Optional.absent()); Optional.absent());
return new Pair<>(decryptedGroupState, signedGroupChange); 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( public DecryptedGroup getUpdatedDecryptedGroup(
DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
) { ) {

View file

@ -22,7 +22,9 @@ public class ErrorUtils {
} }
public static int handleTimestampAndSendMessageResults(long timestamp, List<SendMessageResult> results) { 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); List<String> errors = getErrorMessagesFromSendMessageResults(results);
return handleSendMessageResultErrors(errors); return handleSendMessageResultErrors(errors);
} }