X-Git-Url: https://git.nmode.ca/signal-cli/blobdiff_plain/98dee97cc69b49c0c3b75bb8691ae7ccc266e4d2..a643609ed2b1875c5f5de96aaf8e30b4f1e35e96:/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java diff --git a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index 334cacd8..d39da8a3 100644 --- a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -2,28 +2,54 @@ package org.asamk.signal.manager.helper; import com.google.protobuf.InvalidProtocolBufferException; +import org.asamk.signal.manager.groups.GroupIdV2; +import org.asamk.signal.manager.groups.GroupLinkPassword; +import org.asamk.signal.manager.groups.GroupUtils; +import org.asamk.signal.manager.storage.groups.GroupInfoV2; +import org.asamk.signal.manager.storage.profiles.SignalProfile; +import org.asamk.signal.manager.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.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.groups.GroupSecretParams; +import org.signal.zkgroup.groups.UuidCiphertext; import org.signal.zkgroup.profiles.ProfileKeyCredential; +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.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; import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.Collection; +import java.util.List; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; public class GroupHelper { + private final static Logger logger = LoggerFactory.getLogger(GroupHelper.class); + private final ProfileKeyCredentialProvider profileKeyCredentialProvider; private final ProfileProvider profileProvider; @@ -32,47 +58,101 @@ public class GroupHelper { private final GroupsV2Operations groupsV2Operations; + private final GroupsV2Api groupsV2Api; + + private final GroupAuthorizationProvider groupAuthorizationProvider; + public GroupHelper( final ProfileKeyCredentialProvider profileKeyCredentialProvider, final ProfileProvider profileProvider, final SelfAddressProvider selfAddressProvider, - final GroupsV2Operations groupsV2Operations + final GroupsV2Operations groupsV2Operations, + final GroupsV2Api groupsV2Api, + final GroupAuthorizationProvider groupAuthorizationProvider ) { this.profileKeyCredentialProvider = profileKeyCredentialProvider; this.profileProvider = profileProvider; this.selfAddressProvider = selfAddressProvider; this.groupsV2Operations = groupsV2Operations; + this.groupsV2Api = groupsV2Api; + this.groupAuthorizationProvider = groupAuthorizationProvider; } - public GroupsV2Operations.NewGroup createGroupV2( - String name, Collection members, byte[] avatar - ) { - final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential( - selfAddressProvider.getSelfAddress()); - if (profileKeyCredential == null) { - System.err.println("Cannot create a V2 group as self does not have a versioned profile"); + public DecryptedGroup getDecryptedGroup(final GroupSecretParams groupSecretParams) { + try { + final GroupsV2AuthorizationString groupsV2AuthorizationString = groupAuthorizationProvider.getAuthorizationForToday( + groupSecretParams); + return groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString); + } catch (IOException | VerificationFailedException | InvalidGroupStateException e) { + logger.warn("Failed to retrieve Group V2 info, ignoring: {}", e.getMessage()); return null; } + } - final int noUuidCapability = members.stream() - .filter(address -> !address.getUuid().isPresent()) - .collect(Collectors.toUnmodifiableSet()) - .size(); - if (noUuidCapability > 0) { - System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID."); + 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 members, File avatarFile + ) throws IOException { + final byte[] avatarBytes = readAvatarBytes(avatarFile); + final GroupsV2Operations.NewGroup newGroup = buildNewGroupV2(name, members, avatarBytes); + if (newGroup == null) { return null; } - final int noGv2Capability = members.stream() - .map(profileProvider::getProfile) - .filter(profile -> !profile.getCapabilities().gv2) - .collect(Collectors.toUnmodifiableSet()) - .size(); - if (noGv2Capability > 0) { - System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2."); + final GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams(); + + final GroupsV2AuthorizationString groupAuthForToday; + final DecryptedGroup decryptedGroup; + try { + groupAuthForToday = groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams); + groupsV2Api.putNewGroup(newGroup, groupAuthForToday); + decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday); + } catch (IOException | VerificationFailedException | InvalidGroupStateException e) { + logger.warn("Failed to create V2 group: {}", e.getMessage()); + return null; + } + if (decryptedGroup == null) { + logger.warn("Failed to create V2 group, unknown error!"); + return null; + } + + final GroupIdV2 groupId = GroupUtils.getGroupIdV2(groupSecretParams); + final GroupMasterKey masterKey = groupSecretParams.getMasterKey(); + GroupInfoV2 g = new GroupInfoV2(groupId, masterKey); + g.setGroup(decryptedGroup); + + return g; + } + + 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 buildNewGroupV2( + String name, Collection members, byte[] avatar + ) { + final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential( + selfAddressProvider.getSelfAddress()); + 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; + GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(), Optional.fromNullable(profileKeyCredential)); Set candidates = members.stream() @@ -90,6 +170,203 @@ public class GroupHelper { 0); } + private boolean areMembersValid(final Collection members) { + final Set noUuidCapability = members.stream() + .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 Set noGv2Capability = members.stream() + .map(profileProvider::getProfile) + .filter(profile -> profile != null && !profile.getCapabilities().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(SignalProfile::getName).collect(Collectors.joining(", "))); + return false; + } + + return true; + } + + public Pair updateGroupV2( + GroupInfoV2 groupInfoV2, String name, File avatarFile + ) throws IOException { + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); + + GroupChange.Actions.Builder change = name != null + ? groupOperations.createModifyGroupTitle(name) + : GroupChange.Actions.newBuilder(); + + if (avatarFile != null) { + final byte[] avatarBytes = readAvatarBytes(avatarFile); + String avatarCdnKey = groupsV2Api.uploadAvatar(avatarBytes, + groupSecretParams, + groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams)); + change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder().setAvatar(avatarCdnKey)); + } + + final Optional uuid = this.selfAddressProvider.getSelfAddress().getUuid(); + if (uuid.isPresent()) { + change.setSourceUuid(UuidUtil.toByteString(uuid.get())); + } + + return commitChange(groupInfoV2, change); + } + + public Pair updateGroupV2( + GroupInfoV2 groupInfoV2, Set newMembers + ) throws IOException { + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); + + if (!areMembersValid(newMembers)) { + throw new IOException("Failed to update group"); + } + + Set candidates = newMembers.stream() + .map(member -> new GroupCandidate(member.getUuid().get(), + Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member)))) + .collect(Collectors.toSet()); + + final GroupChange.Actions.Builder change = groupOperations.createModifyGroupMembershipChange(candidates, + selfAddressProvider.getSelfAddress().getUuid().get()); + + final Optional uuid = this.selfAddressProvider.getSelfAddress().getUuid(); + if (uuid.isPresent()) { + change.setSourceUuid(UuidUtil.toByteString(uuid.get())); + } + + return commitChange(groupInfoV2, change); + } + + public Pair leaveGroup(GroupInfoV2 groupInfoV2) throws IOException { + List pendingMembersList = groupInfoV2.getGroup().getPendingMembersList(); + final UUID selfUuid = selfAddressProvider.getSelfAddress().getUuid().get(); + Optional selfPendingMember = DecryptedGroupUtil.findPendingByUuid(pendingMembersList, + selfUuid); + + if (selfPendingMember.isPresent()) { + return revokeInvites(groupInfoV2, Set.of(selfPendingMember.get())); + } else { + return ejectMembers(groupInfoV2, Set.of(selfUuid)); + } + } + + 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 acceptInvite(GroupInfoV2 groupInfoV2) throws IOException { + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + 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"); + } + + final GroupChange.Actions.Builder change = groupOperations.createAcceptInviteChange(profileKeyCredential); + + final Optional uuid = selfAddress.getUuid(); + if (uuid.isPresent()) { + change.setSourceUuid(UuidUtil.toByteString(uuid.get())); + } + + return commitChange(groupInfoV2, change); + } + + public Pair revokeInvites( + GroupInfoV2 groupInfoV2, Set pendingMembers + ) throws IOException { + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); + final Set uuidCipherTexts = pendingMembers.stream().map(member -> { + try { + return new UuidCiphertext(member.getUuidCipherText().toByteArray()); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + }).collect(Collectors.toSet()); + return commitChange(groupInfoV2, groupOperations.createRemoveInvitationChange(uuidCipherTexts)); + } + + public Pair ejectMembers(GroupInfoV2 groupInfoV2, Set uuids) throws IOException { + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); + return commitChange(groupInfoV2, groupOperations.createRemoveMembersChange(uuids)); + } + + private Pair commitChange( + GroupInfoV2 groupInfoV2, GroupChange.Actions.Builder change + ) throws IOException { + final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); + final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams); + final DecryptedGroup previousGroupState = groupInfoV2.getGroup(); + final int nextRevision = previousGroupState.getRevision() + 1; + final GroupChange.Actions changeActions = change.setRevision(nextRevision).build(); + final DecryptedGroupChange decryptedChange; + final DecryptedGroup decryptedGroupState; + + try { + decryptedChange = groupOperations.decryptChange(changeActions, + selfAddressProvider.getSelfAddress().getUuid().get()); + decryptedGroupState = DecryptedGroupUtil.apply(previousGroupState, decryptedChange); + } catch (VerificationFailedException | InvalidGroupStateException | NotAbleToApplyGroupV2ChangeException e) { + throw new IOException(e); + } + + 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 ) {