]> nmode's Git Repositories - signal-cli/blobdiff - lib/src/main/java/org/asamk/signal/manager/helper/GroupV2Helper.java
Fix NPR when loading an inactive group
[signal-cli] / lib / src / main / java / org / asamk / signal / manager / helper / GroupV2Helper.java
index a72c158d9284ef72d53f47fb5544ef888aefd8a7..8eb66843335af224dcac1b9ba51bb2919458e2aa 100644 (file)
 package org.asamk.signal.manager.helper;
 
-import com.google.protobuf.InvalidProtocolBufferException;
-
+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.GroupUtils;
+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.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.Pair;
-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.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.groupsv2.NotAbleToApplyGroupV2ChangeException;
+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.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;
 
-public class GroupV2Helper {
-
-    private final static Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
-
-    private final ProfileKeyCredentialProvider profileKeyCredentialProvider;
+import okio.ByteString;
 
-    private final ProfileProvider profileProvider;
+class GroupV2Helper {
 
-    private final SelfRecipientIdProvider selfRecipientIdProvider;
+    private static final Logger logger = LoggerFactory.getLogger(GroupV2Helper.class);
 
-    private final GroupsV2Operations groupsV2Operations;
+    private final SignalDependencies dependencies;
+    private final Context context;
 
-    private final GroupsV2Api groupsV2Api;
+    private Map<Long, AuthCredentialWithPniResponse> groupApiCredentials;
 
-    private final GroupAuthorizationProvider groupAuthorizationProvider;
-
-    private final SignalServiceAddressResolver addressResolver;
+    GroupV2Helper(final Context context) {
+        this.dependencies = context.getDependencies();
+        this.context = context;
+    }
 
-    public GroupV2Helper(
-            final ProfileKeyCredentialProvider profileKeyCredentialProvider,
-            final ProfileProvider profileProvider,
-            final SelfRecipientIdProvider selfRecipientIdProvider,
-            final GroupsV2Operations groupsV2Operations,
-            final GroupsV2Api groupsV2Api,
-            final GroupAuthorizationProvider groupAuthorizationProvider,
-            final SignalServiceAddressResolver addressResolver
-    ) {
-        this.profileKeyCredentialProvider = profileKeyCredentialProvider;
-        this.profileProvider = profileProvider;
-        this.selfRecipientIdProvider = selfRecipientIdProvider;
-        this.groupsV2Operations = groupsV2Operations;
-        this.groupsV2Api = groupsV2Api;
-        this.groupAuthorizationProvider = groupAuthorizationProvider;
-        this.addressResolver = addressResolver;
+    void clearAuthCredentialCache() {
+        groupApiCredentials = null;
     }
 
-    public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) {
+    DecryptedGroupResponse getDecryptedGroup(final GroupSecretParams groupSecretParams) throws NotAGroupMemberException {
         try {
-            final var groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday(
-                    groupSecretParams);
-            return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
-        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
+            final var groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
+            return dependencies.getGroupsV2Api().getGroup(groupSecretParams, groupsV2AuthorizationString);
+        } catch (NonSuccessfulResponseCodeException e) {
+            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 | InvalidInputException e) {
             logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage());
             return null;
         }
     }
 
-    public DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
-            GroupMasterKey groupMasterKey, GroupLinkPassword password
+    DecryptedGroupJoinInfo getDecryptedGroupJoinInfo(
+            GroupMasterKey groupMasterKey,
+            GroupLinkPassword password
     ) throws IOException, GroupLinkNotActiveException {
         var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
 
-        return groupsV2Api.getGroupJoinInfo(groupSecretParams,
-                Optional.fromNullable(password).transform(GroupLinkPassword::serialize),
-                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
+        return dependencies.getGroupsV2Api()
+                .getGroupJoinInfo(groupSecretParams,
+                        Optional.ofNullable(password).map(GroupLinkPassword::serialize),
+                        getGroupAuthForToday(groupSecretParams));
     }
 
-    public 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;
         }
@@ -113,200 +154,233 @@ public class GroupV2Helper {
         final var groupSecretParams = newGroup.getGroupSecretParams();
 
         final GroupsV2AuthorizationString groupAuthForToday;
-        final DecryptedGroup decryptedGroup;
+        final DecryptedGroupResponse response;
         try {
-            groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams);
-            groupsV2Api.putNewGroup(newGroup, groupAuthForToday);
-            decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday);
-        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
+            groupAuthForToday = getGroupAuthForToday(groupSecretParams);
+            dependencies.getGroupsV2Api().putNewGroup(newGroup, groupAuthForToday);
+            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);
-    }
-
-    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;
+        return new Pair<>(g, response);
     }
 
-    private GroupsV2Operations.NewGroup buildNewGroup(
-            String name, Set<RecipientId> members, byte[] avatar
-    ) {
-        final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientIdProvider.getSelfRecipientId());
+    private GroupsV2Operations.NewGroup buildNewGroup(String name, Set<RecipientId> members, byte[] avatar) {
+        final var profileKeyCredential = context.getProfileHelper()
+                .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;
-
-        var self = new GroupCandidate(addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid()
-                .orNull(), Optional.fromNullable(profileKeyCredential));
-        var candidates = members.stream()
-                .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
-                        Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
+        final var self = new GroupCandidate(getSelfAci(), Optional.of(profileKeyCredential));
+        final var memberList = new ArrayList<>(members);
+        final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
+        final var uuids = memberList.stream()
+                .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId());
+        var candidates = Utils.zip(uuids,
+                        credentials,
+                        (uuid, credential) -> new GroupCandidate(uuid, Optional.ofNullable(credential)))
                 .collect(Collectors.toSet());
 
         final var groupSecretParams = GroupSecretParams.generate();
-        return groupsV2Operations.createNewGroup(groupSecretParams,
-                name,
-                Optional.fromNullable(avatar),
-                self,
-                candidates,
-                Member.Role.DEFAULT,
-                0);
-    }
-
-    private boolean areMembersValid(final Set<RecipientId> members) {
-        final var noUuidCapability = members.stream()
-                .map(addressResolver::resolveSignalServiceAddress)
-                .filter(address -> !address.getUuid().isPresent())
-                .map(SignalServiceAddress::getLegacyIdentifier)
-                .collect(Collectors.toSet());
-        if (noUuidCapability.size() > 0) {
-            logger.warn("Cannot create a V2 group as some members don't have a UUID: {}",
-                    String.join(", ", noUuidCapability));
-            return false;
-        }
-
-        final var noGv2Capability = members.stream()
-                .map(profileProvider::getProfile)
-                .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;
+        return dependencies.getGroupsV2Operations()
+                .createNewGroup(groupSecretParams,
+                        name,
+                        Optional.ofNullable(avatar),
+                        self,
+                        candidates,
+                        Member.Role.DEFAULT,
+                        0);
     }
 
-    public 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 = groupsV2Operations.forGroup(groupSecretParams);
+        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.createModifyGroupDescription(description));
+            change.modifyDescription(groupOperations.createModifyGroupDescriptionAction(description).build());
         }
 
         if (avatarFile != null) {
-            final var avatarBytes = readAvatarBytes(avatarFile);
-            var avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes,
-                    groupSecretParams,
-                    groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams));
-            change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey));
+            var avatarCdnKey = dependencies.getGroupsV2Api()
+                    .uploadAvatar(avatarFile, groupSecretParams, getGroupAuthForToday(groupSecretParams));
+            change.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar(avatarCdnKey).build());
         }
 
-        final var uuid = addressResolver.resolveSignalServiceAddress(this.selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid();
-        if (uuid.isPresent()) {
-            change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
-        }
+        change.sourceServiceId(getSelfAci().toByteString());
 
         return commitChange(groupInfoV2, change);
     }
 
-    public 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");
-        }
-
-        var candidates = newMembers.stream()
-                .map(member -> new GroupCandidate(addressResolver.resolveSignalServiceAddress(member).getUuid().get(),
-                        Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
+        final var memberList = new ArrayList<>(newMembers);
+        final var credentials = context.getProfileHelper().getExpiringProfileKeyCredential(memberList).stream();
+        final var uuids = memberList.stream()
+                .map(member -> context.getRecipientHelper().resolveSignalServiceAddress(member).getServiceId());
+        var candidates = Utils.zip(uuids,
+                        credentials,
+                        (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 uuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid()
-                .get();
-        final var change = groupOperations.createModifyGroupMembershipChange(candidates, uuid);
+        final var aci = getSelfAci();
+        final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci);
 
-        change.setSourceUuid(UuidUtil.toByteString(uuid));
+        change.sourceServiceId(getSelfAci().toByteString());
 
         return commitChange(groupInfoV2, change);
     }
 
-    public Pair<DecryptedGroup, GroupChange> leaveGroup(GroupInfoV2 groupInfoV2) throws IOException {
-        var pendingMembersList = groupInfoV2.getGroup().getPendingMembersList();
-        final var selfUuid = addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                .getUuid()
-                .get();
-        var selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, selfUuid);
+    Pair<DecryptedGroup, GroupChangeResponse> leaveGroup(
+            GroupInfoV2 groupInfoV2,
+            Set<RecipientId> membersToMakeAdmin
+    ) throws IOException {
+        var pendingMembersList = groupInfoV2.getGroup().pendingMembers;
+        final var selfAci = getSelfAci();
+        var selfPendingMember = DecryptedGroupUtil.findPendingByServiceId(pendingMembersList, selfAci);
 
         if (selfPendingMember.isPresent()) {
             return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get()));
-        } else {
-            return ejectMembers(groupInfoV2, Set.of(selfUuid));
         }
+
+        final var adminUuids = membersToMakeAdmin.stream()
+                .map(context.getRecipientHelper()::resolveSignalServiceAddress)
+                .map(SignalServiceAddress::getServiceId)
+                .map(ServiceId::getRawUuid)
+                .toList();
+        final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+        return commitChange(groupInfoV2, groupOperations.createLeaveAndPromoteMembersToAdmin(selfAci, adminUuids));
     }
 
-    public 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(addressResolver::resolveSignalServiceAddress)
-                .map(SignalServiceAddress::getUuid)
-                .filter(Optional::isPresent)
-                .map(Optional::get)
+                .map(context.getRecipientHelper()::resolveSignalServiceAddress)
+                .map(SignalServiceAddress::getServiceId)
+                .filter(m -> m instanceof ACI)
+                .map(m -> (ACI) m)
                 .collect(Collectors.toSet());
         return ejectMembers(groupInfoV2, memberUuids);
     }
 
-    public 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(addressResolver::resolveSignalServiceAddress)
-                .map(SignalServiceAddress::getUuid)
-                .filter(Optional::isPresent)
-                .map(Optional::get)
-                .map(uuid -> DecryptedGroupUtil.findPendingByUuid(pendingMembersList, uuid))
+                .map(context.getRecipientHelper()::resolveSignalServiceAddress)
+                .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);
     }
 
-    public 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);
     }
 
-    public 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(),
@@ -314,86 +388,164 @@ public class GroupV2Helper {
         return commitChange(groupInfoV2, change);
     }
 
-    public GroupChange joinGroup(
+    Pair<DecryptedGroup, GroupChangeResponse> setEditDetailsPermission(
+            GroupInfoV2 groupInfoV2,
+            GroupPermission permission
+    ) throws IOException {
+        final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+
+        final var accessRequired = toAccessControl(permission);
+        final var change = groupOperations.createChangeAttributesRights(accessRequired);
+        return commitChange(groupInfoV2, change);
+    }
+
+    Pair<DecryptedGroup, GroupChangeResponse> setAddMemberPermission(
+            GroupInfoV2 groupInfoV2,
+            GroupPermission permission
+    ) throws IOException {
+        final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+
+        final var accessRequired = toAccessControl(permission);
+        final var change = groupOperations.createChangeMembershipRights(accessRequired);
+        return commitChange(groupInfoV2, change);
+    }
+
+    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
     ) throws IOException {
         final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
-        final var groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+        final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
 
-        final var selfRecipientId = this.selfRecipientIdProvider.getSelfRecipientId();
-        final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
+        final var selfRecipientId = context.getAccount().getSelfRecipientId();
+        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(UuidUtil.toByteString(addressResolver.resolveSignalServiceAddress(selfRecipientId)
-                .getUuid()
-                .get()));
+        change.sourceServiceId(context.getRecipientHelper()
+                .resolveSignalServiceAddress(selfRecipientId)
+                .getServiceId()
+                .toByteString());
 
-        return commitChange(groupSecretParams, decryptedGroupJoinInfo.getRevision(), change, groupLinkPassword);
+        return commitChange(groupSecretParams, decryptedGroupJoinInfo.revision, change, groupLinkPassword);
     }
 
-    public 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 = this.selfRecipientIdProvider.getSelfRecipientId();
-        final var profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(selfRecipientId);
+        final var selfRecipientId = context.getAccount().getSelfRecipientId();
+        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 uuid = addressResolver.resolveSignalServiceAddress(selfRecipientId).getUuid();
-        if (uuid.isPresent()) {
-            change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
-        }
+        final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getServiceId();
+        change.sourceServiceId(aci.toByteString());
 
         return commitChange(groupInfoV2, change);
     }
 
-    public 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 = addressResolver.resolveSignalServiceAddress(recipientId);
+        final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
         final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
-        final var change = groupOperations.createChangeMemberRole(address.getUuid().get(), newRole);
+        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, 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, GroupChangeResponse> setIsAnnouncementGroup(
+            GroupInfoV2 groupInfoV2,
+            boolean isAnnouncementGroup
+    ) throws IOException {
+        final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
+        final var change = groupOperations.createAnnouncementGroupChange(isAnnouncementGroup);
         return commitChange(groupInfoV2, change);
     }
 
     private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
-        switch (state) {
-            case DISABLED:
-                return AccessControl.AccessRequired.UNSATISFIABLE;
-            case ENABLED:
-                return AccessControl.AccessRequired.ANY;
-            case ENABLED_WITH_APPROVAL:
-                return AccessControl.AccessRequired.ADMINISTRATOR;
-            default:
-                throw new AssertionError();
-        }
+        return switch (state) {
+            case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;
+            case ENABLED -> AccessControl.AccessRequired.ANY;
+            case ENABLED_WITH_APPROVAL -> AccessControl.AccessRequired.ADMINISTRATOR;
+        };
+    }
+
+    private AccessControl.AccessRequired toAccessControl(final GroupPermission permission) {
+        return switch (permission) {
+            case EVERY_MEMBER -> AccessControl.AccessRequired.MEMBER;
+            case ONLY_ADMINS -> AccessControl.AccessRequired.ADMINISTRATOR;
+        };
     }
 
     private GroupsV2Operations.GroupOperations getGroupOperations(final GroupInfoV2 groupInfoV2) {
         final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
-        return groupsV2Operations.forGroup(groupSecretParams);
+        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);
             }
@@ -401,80 +553,172 @@ public class GroupV2Helper {
         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 = groupsV2Operations.forGroup(groupSecretParams);
+        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,
-                    addressResolver.resolveSignalServiceAddress(selfRecipientIdProvider.getSelfRecipientId())
-                            .getUuid()
-                            .get());
+            decryptedChange = groupOperations.decryptChange(changeActions, getSelfAci());
             decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange);
         } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) {
             throw new IOException(e);
         }
 
-        var signedGroupChange = groupsV2Api.patchGroup(changeActions,
-                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
-                Optional.absent());
+        var signedGroupChange = dependencies.getGroupsV2Api()
+                .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 groupsV2Api.patchGroup(changeActions,
-                groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams),
-                Optional.fromNullable(password).transform(GroupLinkPassword::serialize));
+        return dependencies.getGroupsV2Api()
+                .patchGroup(changeActions,
+                        getGroupAuthForToday(groupSecretParams),
+                        Optional.ofNullable(password).map(GroupLinkPassword::serialize));
     }
 
-    public DecryptedGroup getUpdatedDecryptedGroup(
-            DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey
-    ) {
+    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, 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 = groupsV2Operations.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 long currentDaySeconds() {
+        return TimeUnit.DAYS.toSeconds(TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis()));
+    }
+
+    private GroupsV2AuthorizationString getGroupAuthForToday(
+            final GroupSecretParams groupSecretParams
+    ) throws IOException {
+        final var todaySeconds = currentDaySeconds();
+        if (groupApiCredentials == null || !groupApiCredentials.containsKey(todaySeconds)) {
+            // Returns credentials for the next 7 days
+            groupApiCredentials = dependencies.getGroupsV2Api()
+                    .getCredentials(todaySeconds)
+                    .getAuthCredentialWithPniResponseHashMap();
+            // TODO cache credentials on disk until they expire
+        }
+        try {
+            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();
+    }
 }