From: AsamK Date: Sun, 13 Dec 2020 11:01:18 +0000 (+0100) Subject: Implement updating of v2 groups X-Git-Tag: v0.7.0~3 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/1fd62ee342eb224017e044def60d5ffbf157be43?ds=sidebyside Implement updating of v2 groups --- diff --git a/src/main/java/org/asamk/signal/manager/Manager.java b/src/main/java/org/asamk/signal/manager/Manager.java index 000455ac..887f9e42 100644 --- a/src/main/java/org/asamk/signal/manager/Manager.java +++ b/src/main/java/org/asamk/signal/manager/Manager.java @@ -43,6 +43,7 @@ import org.signal.libsignal.metadata.ProtocolLegacyMessageException; import org.signal.libsignal.metadata.ProtocolNoSessionException; import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException; import org.signal.libsignal.metadata.SelfSendException; +import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.zkgroup.InvalidInputException; @@ -213,7 +214,9 @@ public class Manager implements Closeable { this.groupHelper = new GroupHelper(this::getRecipientProfileKeyCredential, this::getRecipientProfile, account::getSelfAddress, - groupsV2Operations); + groupsV2Operations, + groupsV2Api, + this::getGroupAuthForToday); } public String getUsername() { @@ -752,36 +755,6 @@ public class Manager implements Closeable { return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress())); } - private GroupInfoV2 createGroupV2( - String name, Collection members, InputStream avatar - ) throws IOException { - byte[] avatarBytes = avatar == null ? null : IOUtils.readFully(avatar); - final GroupsV2Operations.NewGroup newGroup = groupHelper.createGroupV2(name, members, avatarBytes); - final GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams(); - - final GroupsV2AuthorizationString groupAuthForToday; - final DecryptedGroup decryptedGroup; - try { - groupAuthForToday = getGroupAuthForToday(groupSecretParams); - groupsV2Api.putNewGroup(newGroup, groupAuthForToday); - decryptedGroup = groupsV2Api.getGroup(groupSecretParams, groupAuthForToday); - } catch (IOException | VerificationFailedException | InvalidGroupStateException e) { - System.err.println("Failed to create V2 group: " + e.getMessage()); - return null; - } - if (decryptedGroup == null) { - System.err.println("Failed to create V2 group!"); - return null; - } - - final byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize(); - final GroupMasterKey masterKey = groupSecretParams.getMasterKey(); - GroupInfoV2 g = new GroupInfoV2(groupId, masterKey); - g.setGroup(decryptedGroup); - - return g; - } - private Pair> sendUpdateGroupMessage( byte[] groupId, String name, Collection members, String avatarFile ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException { @@ -789,8 +762,7 @@ public class Manager implements Closeable { SignalServiceDataMessage.Builder messageBuilder; if (groupId == null) { // Create new group - InputStream avatar = avatarFile == null ? null : new FileInputStream(avatarFile); - GroupInfoV2 gv2 = createGroupV2(name, members, avatar); + GroupInfoV2 gv2 = groupHelper.createGroupV2(name, members, avatarFile); if (gv2 == null) { GroupInfoV1 gv1 = new GroupInfoV1(KeyUtils.createGroupId()); gv1.addMembers(Collections.singleton(account.getSelfAddress())); @@ -798,18 +770,41 @@ public class Manager implements Closeable { messageBuilder = getGroupUpdateMessageBuilder(gv1); g = gv1; } else { - messageBuilder = getGroupUpdateMessageBuilder(gv2); + messageBuilder = getGroupUpdateMessageBuilder(gv2, null); g = gv2; } } else { GroupInfo group = getGroupForSending(groupId); - if (!(group instanceof GroupInfoV1)) { - throw new RuntimeException("TODO Not implemented!"); + if (group instanceof GroupInfoV2) { + Pair groupGroupChangePair = null; + if (members != null) { + final Set newMembers = new HashSet<>(members); + newMembers.removeAll(group.getMembers()); + if (newMembers.size() > 0) { + groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, newMembers); + } + } + if (groupGroupChangePair == null || name != null || avatarFile != null) { + if (groupGroupChangePair != null) { + ((GroupInfoV2) group).setGroup(groupGroupChangePair.first()); + messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group, + groupGroupChangePair.second().toByteArray()); + sendMessage(messageBuilder, group.getMembersWithout(account.getSelfAddress())); + } + + groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, name, avatarFile); + } + + ((GroupInfoV2) group).setGroup(groupGroupChangePair.first()); + messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group, + groupGroupChangePair.second().toByteArray()); + g = group; + } else { + GroupInfoV1 gv1 = (GroupInfoV1) group; + updateGroupV1(gv1, name, members, avatarFile); + messageBuilder = getGroupUpdateMessageBuilder(gv1); + g = gv1; } - GroupInfoV1 gv1 = (GroupInfoV1) group; - updateGroupV1(gv1, name, members, avatarFile); - messageBuilder = getGroupUpdateMessageBuilder(gv1); - g = gv1; } account.getGroupStore().updateGroup(g); @@ -899,11 +894,10 @@ public class Manager implements Closeable { .withExpiration(g.getMessageExpirationTime()); } - private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g) { + private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV2 g, byte[] signedGroupChange) { SignalServiceGroupV2.Builder group = SignalServiceGroupV2.newBuilder(g.getMasterKey()) .withRevision(g.getGroup().getRevision()) -// .withSignedGroupChange() // TODO - ; + .withSignedGroupChange(signedGroupChange); return SignalServiceDataMessage.newBuilder() .asGroupMessage(group.build()) .withExpiration(g.getMessageExpirationTime()); @@ -1427,16 +1421,20 @@ public class Manager implements Closeable { private GroupsV2AuthorizationString getGroupAuthForToday( final GroupSecretParams groupSecretParams - ) throws IOException, VerificationFailedException { + ) throws IOException { final int today = currentTimeDays(); // Returns credentials for the next 7 days final HashMap credentials = groupsV2Api.getCredentials(today); // TODO cache credentials until they expire AuthCredentialResponse authCredentialResponse = credentials.get(today); - return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), - today, - groupSecretParams, - authCredentialResponse); + try { + return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), + today, + groupSecretParams, + authCredentialResponse); + } catch (VerificationFailedException e) { + throw new IOException(e); + } } private List handleSignalServiceDataMessage( diff --git a/src/main/java/org/asamk/signal/manager/helper/GroupAuthorizationProvider.java b/src/main/java/org/asamk/signal/manager/helper/GroupAuthorizationProvider.java new file mode 100644 index 00000000..d26ebb06 --- /dev/null +++ b/src/main/java/org/asamk/signal/manager/helper/GroupAuthorizationProvider.java @@ -0,0 +1,11 @@ +package org.asamk.signal.manager.helper; + +import org.signal.zkgroup.groups.GroupSecretParams; +import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; + +import java.io.IOException; + +public interface GroupAuthorizationProvider { + + GroupsV2AuthorizationString getAuthorizationForToday(GroupSecretParams groupSecretParams) throws IOException; +} 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..153f8cd0 100644 --- a/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -2,6 +2,8 @@ package org.asamk.signal.manager.helper; import com.google.protobuf.InvalidProtocolBufferException; +import org.asamk.signal.storage.groups.GroupInfoV2; +import org.asamk.signal.util.IOUtils; import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.protos.groups.local.DecryptedGroup; @@ -10,16 +12,24 @@ import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.groups.GroupMasterKey; import org.signal.zkgroup.groups.GroupSecretParams; import org.signal.zkgroup.profiles.ProfileKeyCredential; +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.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.FileInputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.Collection; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; public class GroupHelper { @@ -32,19 +42,69 @@ 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 GroupInfoV2 createGroupV2( + String name, Collection members, String avatarFile + ) throws IOException { + final byte[] avatarBytes = readAvatarBytes(avatarFile); + final GroupsV2Operations.NewGroup newGroup = buildNewGroupV2(name, members, avatarBytes); + if (newGroup == null) { + return null; + } + + 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) { + System.err.println("Failed to create V2 group: " + e.getMessage()); + return null; + } + if (decryptedGroup == null) { + System.err.println("Failed to create V2 group!"); + return null; + } + + final byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize(); + final GroupMasterKey masterKey = groupSecretParams.getMasterKey(); + GroupInfoV2 g = new GroupInfoV2(groupId, masterKey); + g.setGroup(decryptedGroup); + + return g; } - public GroupsV2Operations.NewGroup createGroupV2( + private byte[] readAvatarBytes(final String 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( @@ -90,6 +150,80 @@ public class GroupHelper { 0); } + public Pair updateGroupV2( + GroupInfoV2 groupInfoV2, String name, String 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); + + 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); + } + + 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(change.build(), + groupAuthorizationProvider.getAuthorizationForToday(groupSecretParams), + Optional.absent()); + + return new Pair<>(decryptedGroupState, signedGroupChange); + } + public DecryptedGroup getUpdatedDecryptedGroup( DecryptedGroup group, byte[] signedGroupChange, GroupMasterKey groupMasterKey ) {