### 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
*--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.
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());
--- /dev/null
+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;
+ }
+ }
+}
targetTimestamp,
ns.getList("recipient"));
}
- handleTimestampAndSendMessageResults(results.first(), results.second());
- return 0;
+ return handleTimestampAndSendMessageResults(results.first(), results.second());
} catch (IOException e) {
handleIOException(e);
return 3;
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) {
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;
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;
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 {
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);
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) {
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;
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;
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 {
}
}
+ 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);
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
) {
}
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);
}