mirror of
https://github.com/AsamK/signal-cli
synced 2025-08-29 18:40:39 +00:00
Implement join group via invitation link
This commit is contained in:
parent
9912da9546
commit
445e8592c4
9 changed files with 239 additions and 56 deletions
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue