package org.asamk.signal.manager.helper;
-import com.google.protobuf.InvalidProtocolBufferException;
-
-import org.asamk.signal.manager.SignalDependencies;
+import org.asamk.signal.manager.api.GroupLinkState;
+import org.asamk.signal.manager.api.GroupPermission;
+import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.groups.GroupLinkPassword;
-import org.asamk.signal.manager.groups.GroupLinkState;
-import org.asamk.signal.manager.groups.GroupPermission;
import org.asamk.signal.manager.groups.GroupUtils;
-import org.asamk.signal.manager.groups.NotAGroupMemberException;
+import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
-import org.asamk.signal.manager.storage.recipients.Profile;
import org.asamk.signal.manager.storage.recipients.RecipientId;
-import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils;
+import org.signal.libsignal.zkgroup.InvalidInputException;
+import org.signal.libsignal.zkgroup.VerificationFailedException;
+import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
+import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
+import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
+import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
+import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.GroupChange;
+import org.signal.storageservice.protos.groups.GroupChangeResponse;
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.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
-import org.signal.zkgroup.InvalidInputException;
-import org.signal.zkgroup.VerificationFailedException;
-import org.signal.zkgroup.auth.AuthCredentialResponse;
-import org.signal.zkgroup.groups.GroupMasterKey;
-import org.signal.zkgroup.groups.GroupSecretParams;
-import org.signal.zkgroup.groups.UuidCiphertext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.whispersystems.libsignal.util.guava.Optional;
+import org.whispersystems.signalservice.api.groupsv2.DecryptChangeVerificationMode;
+import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupResponse;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
+import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
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.groupsv2.NotAbleToApplyGroupV2ChangeException;
-import org.whispersystems.signalservice.api.push.ACI;
+import org.whispersystems.signalservice.api.push.ServiceId;
+import org.whispersystems.signalservice.api.push.ServiceId.ACI;
+import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
+import org.whispersystems.signalservice.api.util.UuidUtil;
+import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
-import java.io.File;
-import java.io.FileInputStream;
import java.io.IOException;
-import java.io.InputStream;
import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import okio.ByteString;
class GroupV2Helper {
- private final static Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
+ private static final Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
private final SignalDependencies dependencies;
private final Context context;
- private HashMap<Integer, AuthCredentialResponse> groupApiCredentials;
+ private Map<Long, AuthCredentialWithPniResponse> groupApiCredentials;
GroupV2Helper(final Context context) {
this.dependencies = context.getDependencies();
this.context = context;
}
- DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) throws NotAGroupMemberException {
+ void clearAuthCredentialCache() {
+ groupApiCredentials = null;
+ }
+
+ DecryptedGroupResponse getDecryptedGroup(final GroupSecretParams groupSecretParams) throws NotAGroupMemberException {
try {
final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
return dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupsV2AuthorizationString);
} catch (NonSuccessfulResponseCodeException e) {
- if (e.getCode() == 403) {
+ if (e.code == 403) {
throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
}
logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
return null;
- } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
+ } catch (IOException | VerificationFailedException | InvalidGroupStateException | InvalidInputException e) {
logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
return null;
}
}
DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
- GroupMasterKey groupMasterKey, GroupLinkPassword password
+ GroupMasterKey groupMasterKey,
+ GroupLinkPassword password
) throws IOException, GroupLinkNotActiveException {
var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
return dependencies.getGroupsV2Api()
.getGroupJoinInfo(groupSecretParams,
- Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
+ Optional.ofNullable(password).map(GroupLinkPassword::serialize),
getGroupAuthForToday(groupSecretParams));
}
- Pair<GroupInfoV2, DecryptedGroup> createGroup(
- String name, Set<RecipientId> members, File avatarFile
- ) throws IOException {
- final var avatarBytes = readAvatarBytes(avatarFile);
- final var newGroup = buildNewGroup(name, members, avatarBytes);
+ GroupHistoryPage getDecryptedGroupHistoryPage(
+ final GroupSecretParams groupSecretParams,
+ int fromRevision,
+ long sendEndorsementsExpirationMs
+ ) throws NotAGroupMemberException {
+ try {
+ final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
+ return dependencies.getGroupsV2Api()
+ .getGroupHistoryPage(groupSecretParams,
+ fromRevision,
+ groupsV2AuthorizationString,
+ false,
+ sendEndorsementsExpirationMs);
+ } catch (NotInGroupException e) {
+ throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
+ } catch (NonSuccessfulResponseCodeException e) {
+ if (e.code == 403) {
+ throw new NotAGroupMemberException(GroupUtils.getGroupIdV2(groupSecretParams), null);
+ }
+ logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
+ return null;
+ } catch (IOException | VerificationFailedException | InvalidGroupStateException | InvalidInputException e) {
+ logger.warn("Failed to retrieve Group V2 history, ignoring: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ int findRevisionWeWereAdded(DecryptedGroup partialDecryptedGroup) {
+ ByteString aciBytes = getSelfAci().toByteString();
+ ByteString pniBytes = getSelfPni().toByteString();
+ for (DecryptedMember decryptedMember : partialDecryptedGroup.members) {
+ if (decryptedMember.aciBytes.equals(aciBytes) || decryptedMember.pniBytes.equals(pniBytes)) {
+ return decryptedMember.joinedAtRevision;
+ }
+ }
+ return partialDecryptedGroup.revision;
+ }
+
+ Pair<GroupInfoV2, DecryptedGroupResponse> createGroup(String name, Set<RecipientId> members, byte[] avatarFile) {
+ final var newGroup = buildNewGroup(name, members, avatarFile);
if (newGroup == null) {
return null;
}
final var groupSecretParams = newGroup.getGroupSecretParams();
final GroupsV2AuthorizationString groupAuthForToday;
- final DecryptedGroup decryptedGroup;
+ final DecryptedGroupResponse response;
try {
groupAuthForToday = getGroupAuthForToday(groupSecretParams);
dependencies.getGroupsV2Api().putNewGroup(newGroup, groupAuthForToday);
- decryptedGroup = dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupAuthForToday);
- } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
+ response = dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupAuthForToday);
+ } catch (IOException | VerificationFailedException | InvalidGroupStateException | InvalidInputException e) {
logger.warn("Failed to create V2 group: {}", e.getMessage());
return null;
}
- if (decryptedGroup == null) {
+ if (response == null) {
logger.warn("Failed to create V2 group, unknown error!");
return null;
}
final var groupId = GroupUtils.getGroupIdV2(groupSecretParams);
final var masterKey = groupSecretParams.getMasterKey();
- var g = new GroupInfoV2(groupId, masterKey);
+ var g = new GroupInfoV2(groupId, masterKey, context.getAccount().getRecipientResolver());
- return new Pair<>(g, decryptedGroup);
+ return new Pair<>(g, response);
}
- private byte[] readAvatarBytes(final File avatarFile) throws IOException {
- final byte[] avatarBytes;
- try (InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile)) {
- avatarBytes = avatar == null ? null : IOUtils.readFully(avatar);
- }
- return avatarBytes;
- }
-
- private GroupsV2Operations.NewGroup buildNewGroup(
- String name, Set<RecipientId> members, byte[] avatar
- ) {
+ private GroupsV2Operations.NewGroup buildNewGroup(String name, Set<RecipientId> members, byte[] avatar) {
final var profileKeyCredential = context.getProfileHelper()
- .getRecipientProfileKeyCredential(context.getAccount().getSelfRecipientId());
+ .getExpiringProfileKeyCredential(context.getAccount().getSelfRecipientId());
if (profileKeyCredential == null) {
logger.warn("Cannot create a V2 group as self does not have a versioned profile");
return null;
}
- if (!areMembersValid(members)) return null;
-
- final var self = new GroupCandidate(getSelfAci().uuid(), Optional.fromNullable(profileKeyCredential));
+ final var self = new GroupCandidate(getSelfAci(), Optional.of(profileKeyCredential));
final var memberList = new ArrayList<>(members);
- final var credentials = context.getProfileHelper().getRecipientProfileKeyCredential(memberList).stream();
+ final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
final var uuids = memberList.stream()
- .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getAci().uuid());
+ .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId());
var candidates = Utils.zip(uuids,
credentials,
- (uuid, credential) -> new GroupCandidate(uuid, Optional.fromNullable(credential)))
+ (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
.collect(Collectors.toSet());
final var groupSecretParams = GroupSecretParams.generate();
return dependencies.getGroupsV2Operations()
.createNewGroup(groupSecretParams,
name,
- Optional.fromNullable(avatar),
+ Optional.ofNullable(avatar),
self,
candidates,
Member.Role.DEFAULT,
0);
}
- private boolean areMembersValid(final Set<RecipientId> members) {
- final var noGv2Capability = context.getProfileHelper()
- .getRecipientProfile(new ArrayList<>(members))
- .stream()
- .filter(profile -> profile != null && !profile.getCapabilities().contains(Profile.Capability.gv2))
- .collect(Collectors.toSet());
- if (noGv2Capability.size() > 0) {
- logger.warn("Cannot create a V2 group as some members don't support Groups V2: {}",
- noGv2Capability.stream().map(Profile::getDisplayName).collect(Collectors.joining(", ")));
- return false;
- }
-
- return true;
- }
-
- Pair<DecryptedGroup, GroupChange> updateGroup(
- GroupInfoV2 groupInfoV2, String name, String description, File avatarFile
+ Pair<DecryptedGroup, GroupChangeResponse> updateGroup(
+ GroupInfoV2 groupInfoV2,
+ String name,
+ String description,
+ byte[] avatarFile
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
- var change = name != null ? groupOperations.createModifyGroupTitle(name) : GroupChange.Actions.newBuilder();
+ var change = name != null ? groupOperations.createModifyGroupTitle(name) : new GroupChange.Actions.Builder();
if (description != null) {
- change.setModifyDescription(groupOperations.createModifyGroupDescriptionAction(description));
+ change.modifyDescription(groupOperations.createModifyGroupDescriptionAction(description).build());
}
if (avatarFile != null) {
- final var avatarBytes = readAvatarBytes(avatarFile);
var avatarCdnKey = dependencies.getGroupsV2Api()
- .uploadAvatar(avatarBytes, groupSecretParams, getGroupAuthForToday(groupSecretParams));
- change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
+ .uploadAvatar(avatarFile, groupSecretParams, getGroupAuthForToday(groupSecretParams));
+ change.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar(avatarCdnKey).build());
}
- change.setSourceUuid(getSelfAci().toByteString());
+ change.sourceServiceId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> addMembers(
- GroupInfoV2 groupInfoV2, Set<RecipientId> newMembers
+ Pair<DecryptedGroup, GroupChangeResponse> addMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> newMembers
) throws IOException {
GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
- if (!areMembersValid(newMembers)) {
- throw new IOException("Failed to update group");
- }
-
final var memberList = new ArrayList<>(newMembers);
- final var credentials = context.getProfileHelper().getRecipientProfileKeyCredential(memberList).stream();
+ final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
final var uuids = memberList.stream()
- .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getAci().uuid());
+ .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId());
var candidates = Utils.zip(uuids,
credentials,
- (uuid, credential) -> new GroupCandidate(uuid, Optional.fromNullable(credential)))
+ (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
+ .collect(Collectors.toSet());
+ final var bannedUuids = groupInfoV2.getBannedMembers()
+ .stream()
+ .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId())
.collect(Collectors.toSet());
final var aci = getSelfAci();
- final var change = groupOperations.createModifyGroupMembershipChange(candidates, aci.uuid());
+ final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci);
- change.setSourceUuid(getSelfAci().toByteString());
+ change.sourceServiceId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> leaveGroup(
- GroupInfoV2 groupInfoV2, Set<RecipientId> membersToMakeAdmin
+ Pair<DecryptedGroup, GroupChangeResponse> leaveGroup(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> membersToMakeAdmin
) throws IOException {
- var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
+ var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
final var selfAci = getSelfAci();
- var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfAci.uuid());
+ var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
if (selfPendingMember.isPresent()) {
return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
final var adminUuids = membersToMakeAdmin.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
- .map(SignalServiceAddress::getAci)
- .map(ACI::uuid)
+ .map(SignalServiceAddress::getServiceId)
+ .map(ServiceId::getRawUuid)
.toList();
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
- return commitChange(groupInfoV2,
- groupOperations.createLeaveAndPromoteMembersToAdmin(selfAci.uuid(), adminUuids));
+ return commitChange(groupInfoV2, groupOperations.createLeaveAndPromoteMembersToAdmin(selfAci, adminUuids));
}
- Pair<DecryptedGroup, GroupChange> removeMembers(
- GroupInfoV2 groupInfoV2, Set<RecipientId> members
+ Pair<DecryptedGroup, GroupChangeResponse> removeMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> members
) throws IOException {
final var memberUuids = members.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
- .map(SignalServiceAddress::getAci)
- .map(ACI::uuid)
+ .map(SignalServiceAddress::getServiceId)
+ .filter(m -> m instanceof ACI)
+ .map(m -> (ACI) m)
.collect(Collectors.toSet());
return ejectMembers(groupInfoV2, memberUuids);
}
- Pair<DecryptedGroup, GroupChange> revokeInvitedMembers(
- GroupInfoV2 groupInfoV2, Set<RecipientId> members
+ Pair<DecryptedGroup, GroupChangeResponse> approveJoinRequestMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> members
) throws IOException {
- var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
final var memberUuids = members.stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress)
- .map(SignalServiceAddress::getAci)
- .map(ACI::uuid)
- .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
+ .map(SignalServiceAddress::getServiceId)
+ .map(ServiceId::getRawUuid)
+ .collect(Collectors.toSet());
+ return approveJoinRequest(groupInfoV2, memberUuids);
+ }
+
+ Pair<DecryptedGroup, GroupChangeResponse> refuseJoinRequestMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> members
+ ) throws IOException {
+ final var memberUuids = members.stream()
+ .map(context.getRecipientHelper()::resolveSignalServiceAddress)
+ .map(SignalServiceAddress::getServiceId)
+ .collect(Collectors.toSet());
+ return refuseJoinRequest(groupInfoV2, memberUuids);
+ }
+
+ Pair<DecryptedGroup, GroupChangeResponse> revokeInvitedMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> members
+ ) throws IOException {
+ var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
+ final var memberUuids = members.stream()
+ .map(context.getRecipientHelper()::resolveSignalServiceAddress)
+ .map(SignalServiceAddress::getServiceId)
+ .map(uuid -> DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, uuid))
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toSet());
return revokeInvites(groupInfoV2, memberUuids);
}
- Pair<DecryptedGroup, GroupChange> resetGroupLinkPassword(GroupInfoV2 groupInfoV2) throws IOException {
+ Pair<DecryptedGroup, GroupChangeResponse> banMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> block
+ ) throws IOException {
+ GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+
+ final var serviceIds = block.stream()
+ .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId())
+ .collect(Collectors.toSet());
+
+ final var change = groupOperations.createBanServiceIdsChange(serviceIds,
+ false,
+ groupInfoV2.getGroup().bannedMembers);
+
+ change.sourceServiceId(getSelfAci().toByteString());
+
+ return commitChange(groupInfoV2, change);
+ }
+
+ Pair<DecryptedGroup, GroupChangeResponse> unbanMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<RecipientId> block
+ ) throws IOException {
+ GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+
+ final var serviceIds = block.stream()
+ .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId())
+ .collect(Collectors.toSet());
+
+ final var change = groupOperations.createUnbanServiceIdsChange(serviceIds);
+
+ change.sourceServiceId(getSelfAci().toByteString());
+
+ return commitChange(groupInfoV2, change);
+ }
+
+ Pair<DecryptedGroup, GroupChangeResponse> resetGroupLinkPassword(GroupInfoV2 groupInfoV2) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var newGroupLinkPassword = GroupLinkPassword.createNew().serialize();
final var change = groupOperations.createModifyGroupLinkPasswordChange(newGroupLinkPassword);
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> setGroupLinkState(
- GroupInfoV2 groupInfoV2, GroupLinkState state
+ Pair<DecryptedGroup, GroupChangeResponse> setGroupLinkState(
+ GroupInfoV2 groupInfoV2,
+ GroupLinkState state
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var accessRequired = toAccessControl(state);
- final var requiresNewPassword = state != GroupLinkState.DISABLED && groupInfoV2.getGroup()
- .getInviteLinkPassword()
- .isEmpty();
+ final var requiresNewPassword = state != GroupLinkState.DISABLED
+ && groupInfoV2.getGroup().inviteLinkPassword.toByteArray().length == 0;
final var change = requiresNewPassword ? groupOperations.createModifyGroupLinkPasswordAndRightsChange(
GroupLinkPassword.createNew().serialize(),
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> setEditDetailsPermission(
- GroupInfoV2 groupInfoV2, GroupPermission permission
+ Pair<DecryptedGroup, GroupChangeResponse> setEditDetailsPermission(
+ GroupInfoV2 groupInfoV2,
+ GroupPermission permission
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> setAddMemberPermission(
- GroupInfoV2 groupInfoV2, GroupPermission permission
+ Pair<DecryptedGroup, GroupChangeResponse> setAddMemberPermission(
+ GroupInfoV2 groupInfoV2,
+ GroupPermission permission
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
return commitChange(groupInfoV2, change);
}
- GroupChange joinGroup(
+ Pair<DecryptedGroup, GroupChangeResponse> updateSelfProfileKey(GroupInfoV2 groupInfoV2) throws IOException {
+ Optional<DecryptedMember> selfInGroup = groupInfoV2.getGroup() == null
+ ? Optional.empty()
+ : DecryptedGroupUtil.findMemberByAci(groupInfoV2.getGroup().members, getSelfAci());
+ if (selfInGroup.isEmpty()) {
+ logger.trace("Not updating group, self not in group " + groupInfoV2.getGroupId().toBase64());
+ return null;
+ }
+
+ final var profileKey = context.getAccount().getProfileKey();
+ if (Arrays.equals(profileKey.serialize(), selfInGroup.get().profileKey.toByteArray())) {
+ logger.trace("Not updating group, own Profile Key is already up to date in group "
+ + groupInfoV2.getGroupId().toBase64());
+ return null;
+ }
+ logger.debug("Updating own profile key in group " + groupInfoV2.getGroupId().toBase64());
+
+ final var selfRecipientId = context.getAccount().getSelfRecipientId();
+ final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
+ if (profileKeyCredential == null) {
+ logger.trace("Cannot update profile key as self does not have a versioned profile");
+ return null;
+ }
+
+ final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+ final var change = groupOperations.createUpdateProfileKeyCredentialChange(profileKeyCredential);
+ change.sourceServiceId(getSelfAci().toByteString());
+ return commitChange(groupInfoV2, change);
+ }
+
+ GroupChangeResponse joinGroup(
GroupMasterKey groupMasterKey,
GroupLinkPassword groupLinkPassword,
DecryptedGroupJoinInfo decryptedGroupJoinInfo
final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
final var selfRecipientId = context.getAccount().getSelfRecipientId();
- final var profileKeyCredential = context.getProfileHelper().getRecipientProfileKeyCredential(selfRecipientId);
+ final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
if (profileKeyCredential == null) {
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
}
- var requestToJoin = decryptedGroupJoinInfo.getAddFromInviteLink() == AccessControl.AccessRequired.ADMINISTRATOR;
+ var requestToJoin = decryptedGroupJoinInfo.addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR;
var change = requestToJoin
? groupOperations.createGroupJoinRequest(profileKeyCredential)
: groupOperations.createGroupJoinDirect(profileKeyCredential);
- change.setSourceUuid(context.getRecipientHelper()
+ change.sourceServiceId(context.getRecipientHelper()
.resolveSignalServiceAddress(selfRecipientId)
- .getAci()
+ .getServiceId()
.toByteString());
- return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
+ return commitChange(groupSecretParams, decryptedGroupJoinInfo.revision, change, groupLinkPassword);
}
- Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
+ Pair<DecryptedGroup, GroupChangeResponse> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var selfRecipientId = context.getAccount().getSelfRecipientId();
- final var profileKeyCredential = context.getProfileHelper().getRecipientProfileKeyCredential(selfRecipientId);
+ final var profileKeyCredential = context.getProfileHelper().getExpiringProfileKeyCredential(selfRecipientId);
if (profileKeyCredential == null) {
throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
}
final var change = groupOperations.createAcceptInviteChange(profileKeyCredential);
- final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getAci();
- change.setSourceUuid(aci.toByteString());
+ final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getServiceId();
+ change.sourceServiceId(aci.toByteString());
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> setMemberAdmin(
- GroupInfoV2 groupInfoV2, RecipientId recipientId, boolean admin
+ Pair<DecryptedGroup, GroupChangeResponse> setMemberAdmin(
+ GroupInfoV2 groupInfoV2,
+ RecipientId recipientId,
+ boolean admin
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
- final var change = groupOperations.createChangeMemberRole(address.getAci().uuid(), newRole);
- return commitChange(groupInfoV2, change);
+ if (address.getServiceId() instanceof ACI aci) {
+ final var change = groupOperations.createChangeMemberRole(aci, newRole);
+ return commitChange(groupInfoV2, change);
+ } else {
+ throw new IllegalArgumentException("Can't make a PNI a group admin.");
+ }
}
- Pair<DecryptedGroup, GroupChange> setMessageExpirationTimer(
- GroupInfoV2 groupInfoV2, int messageExpirationTimer
+ Pair<DecryptedGroup, GroupChangeResponse> setMessageExpirationTimer(
+ GroupInfoV2 groupInfoV2,
+ int messageExpirationTimer
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createModifyGroupTimerChange(messageExpirationTimer);
return commitChange(groupInfoV2, change);
}
- Pair<DecryptedGroup, GroupChange> setIsAnnouncementGroup(
- GroupInfoV2 groupInfoV2, boolean isAnnouncementGroup
+ Pair<DecryptedGroup, GroupChangeResponse> setIsAnnouncementGroup(
+ GroupInfoV2 groupInfoV2,
+ boolean isAnnouncementGroup
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createAnnouncementGroupChange(isAnnouncementGroup);
return dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
}
- private Pair<DecryptedGroup, GroupChange> revokeInvites(
- GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
+ private Pair<DecryptedGroup, GroupChangeResponse> revokeInvites(
+ GroupInfoV2 groupInfoV2,
+ Set<DecryptedPendingMember> pendingMembers
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var uuidCipherTexts = pendingMembers.stream().map(member -> {
try {
- return new UuidCiphertext(member.getUuidCipherText().toByteArray());
+ return new UuidCiphertext(member.serviceIdCipherText.toByteArray());
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts));
}
- private Pair<DecryptedGroup, GroupChange> ejectMembers(
- GroupInfoV2 groupInfoV2, Set<UUID> uuids
+ private Pair<DecryptedGroup, GroupChangeResponse> approveJoinRequest(
+ GroupInfoV2 groupInfoV2,
+ Set<UUID> uuids
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
- return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids));
+ return commitChange(groupInfoV2, groupOperations.createApproveGroupJoinRequest(uuids));
}
- private Pair<DecryptedGroup, GroupChange> commitChange(
- GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change
+ private Pair<DecryptedGroup, GroupChangeResponse> refuseJoinRequest(
+ GroupInfoV2 groupInfoV2,
+ Set<ServiceId> serviceIds
+ ) throws IOException {
+ final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+ return commitChange(groupInfoV2, groupOperations.createRefuseGroupJoinRequest(serviceIds, false, List.of()));
+ }
+
+ private Pair<DecryptedGroup, GroupChangeResponse> ejectMembers(
+ GroupInfoV2 groupInfoV2,
+ Set<ACI> members
+ ) throws IOException {
+ final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+ return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(members, false, List.of()));
+ }
+
+ private Pair<DecryptedGroup, GroupChangeResponse> commitChange(
+ GroupInfoV2 groupInfoV2,
+ GroupChange.Actions.Builder change
) throws IOException {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
final var previousGroupState = groupInfoV2.getGroup();
- final var nextRevision = previousGroupState.getRevision() + 1;
- final var changeActions = change.setRevision(nextRevision).build();
+ final var nextRevision = previousGroupState.revision + 1;
+ final var changeActions = change.revision(nextRevision).build();
final DecryptedGroupChange decryptedChange;
final DecryptedGroup decryptedGroupState;
try {
- decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci().uuid());
+ decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci());
decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
} catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
throw new IOException(e);
}
var signedGroupChange = dependencies.getGroupsV2Api()
- .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.absent());
+ .patchGroup(changeActions, getGroupAuthForToday(groupSecretParams), Optional.empty());
+
+ groupInfoV2.setGroup(decryptedGroupState);
return new Pair<>(decryptedGroupState, signedGroupChange);
}
- private GroupChange commitChange(
+ private GroupChangeResponse commitChange(
GroupSecretParams groupSecretParams,
int currentRevision,
GroupChange.Actions.Builder change,
GroupLinkPassword password
) throws IOException {
final var nextRevision = currentRevision + 1;
- final var changeActions = change.setRevision(nextRevision).build();
+ final var changeActions = change.revision(nextRevision).build();
return dependencies.getGroupsV2Api()
.patchGroup(changeActions,
getGroupAuthForToday(groupSecretParams),
- Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
+ Optional.ofNullable(password).map(GroupLinkPassword::serialize));
+ }
+
+ Pair<ServiceId, ProfileKey> getAuthoritativeProfileKeyFromChange(final DecryptedGroupChange change) {
+ UUID editor = UuidUtil.fromByteStringOrNull(change.editorServiceIdBytes);
+ final var editorProfileKeyBytes = Stream.concat(Stream.of(change.newMembers.stream(),
+ change.promotePendingMembers.stream(),
+ change.modifiedProfileKeys.stream())
+ .flatMap(Function.identity())
+ .filter(m -> UuidUtil.fromByteString(m.aciBytes).equals(editor))
+ .map(m -> m.profileKey),
+ change.newRequestingMembers.stream()
+ .filter(m -> UuidUtil.fromByteString(m.aciBytes).equals(editor))
+ .map(m -> m.profileKey)).findFirst();
+
+ if (editorProfileKeyBytes.isEmpty()) {
+ return null;
+ }
+
+ ProfileKey profileKey;
+ try {
+ profileKey = new ProfileKey(editorProfileKeyBytes.get().toByteArray());
+ } catch (InvalidInputException e) {
+ logger.debug("Bad profile key in group");
+ return null;
+ }
+
+ return new Pair<>(ACI.from(editor), profileKey);
}
- DecryptedGroup getUpdatedDecryptedGroup(
- DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
- ) {
+ DecryptedGroup getUpdatedDecryptedGroup(DecryptedGroup group, DecryptedGroupChange decryptedGroupChange) {
try {
- final var decryptedGroupChange = getDecryptedGroupChange(signedGroupChange, groupMasterKey);
- if (decryptedGroupChange == null) {
- return null;
- }
return DecryptedGroupUtil.apply(group, decryptedGroupChange);
} catch (NotAbleToApplyGroupV2ChangeException e) {
return null;
}
}
- private DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
+ DecryptedGroupChange getDecryptedGroupChange(byte[] signedGroupChange, GroupMasterKey groupMasterKey) {
if (signedGroupChange != null) {
- var groupOperations = dependencies.getGroupsV2Operations()
- .forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
+ final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+ final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
+ final var groupId = groupSecretParams.getPublicParams().getGroupIdentifier();
try {
- return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true).orNull();
- } catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
+ return groupOperations.decryptChange(GroupChange.ADAPTER.decode(signedGroupChange),
+ DecryptChangeVerificationMode.verify(groupId)).orElse(null);
+ } catch (VerificationFailedException | InvalidGroupStateException | IOException e) {
return null;
}
}
return null;
}
- private static int currentTimeDays() {
- return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis());
+ private static long currentDaySeconds() {
+ return TimeUnit.DAYS.toSeconds(TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()));
}
private GroupsV2AuthorizationString getGroupAuthForToday(
final GroupSecretParams groupSecretParams
) throws IOException {
- final var today = currentTimeDays();
- if (groupApiCredentials == null || !groupApiCredentials.containsKey(today)) {
+ final var todaySeconds = currentDaySeconds();
+ if (groupApiCredentials == null || !groupApiCredentials.containsKey(todaySeconds)) {
// Returns credentials for the next 7 days
- groupApiCredentials = dependencies.getGroupsV2Api().getCredentials(today);
+ groupApiCredentials = dependencies.getGroupsV2Api()
+ .getCredentials(todaySeconds)
+ .getAuthCredentialWithPniResponseHashMap();
// TODO cache credentials on disk until they expire
}
- var authCredentialResponse = groupApiCredentials.get(today);
- final var aci = getSelfAci();
try {
- return dependencies.getGroupsV2Api()
- .getGroupsV2AuthorizationString(aci, today, groupSecretParams, authCredentialResponse);
+ return getAuthorizationString(groupSecretParams, todaySeconds);
+ } catch (VerificationFailedException e) {
+ logger.debug("Group api credentials invalid, renewing and trying again.");
+ groupApiCredentials.clear();
+ }
+
+ groupApiCredentials = dependencies.getGroupsV2Api()
+ .getCredentials(todaySeconds)
+ .getAuthCredentialWithPniResponseHashMap();
+ try {
+ return getAuthorizationString(groupSecretParams, todaySeconds);
} catch (VerificationFailedException e) {
throw new IOException(e);
}
}
+ private GroupsV2AuthorizationString getAuthorizationString(
+ final GroupSecretParams groupSecretParams,
+ final long todaySeconds
+ ) throws VerificationFailedException {
+ var authCredentialResponse = groupApiCredentials.get(todaySeconds);
+ final var aci = getSelfAci();
+ final var pni = getSelfPni();
+ return dependencies.getGroupsV2Api()
+ .getGroupsV2AuthorizationString(aci, pni, todaySeconds, groupSecretParams, authCredentialResponse);
+ }
+
private ACI getSelfAci() {
return context.getAccount().getAci();
}
+
+ private PNI getSelfPni() {
+ return context.getAccount().getPni();
+ }
}