]> nmode's Git Repositories - signal-cli/commitdiff
Implement support for sending/receiving Group V2 messages
authorAsamK <asamk@gmx.de>
Sun, 22 Nov 2020 18:47:10 +0000 (19:47 +0100)
committerAsamK <asamk@gmx.de>
Mon, 23 Nov 2020 21:40:14 +0000 (22:40 +0100)
Requires libzkgroup to work, which is currently only included for x86_64 Linux

Related #354

12 files changed:
src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java
src/main/java/org/asamk/signal/ReceiveMessageHandler.java
src/main/java/org/asamk/signal/commands/ListGroupsCommand.java
src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java
src/main/java/org/asamk/signal/manager/Manager.java
src/main/java/org/asamk/signal/manager/ServiceConfig.java
src/main/java/org/asamk/signal/storage/SignalAccount.java
src/main/java/org/asamk/signal/storage/groups/GroupInfo.java
src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java [new file with mode: 0644]
src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java [new file with mode: 0644]
src/main/java/org/asamk/signal/storage/groups/JsonGroupStore.java
src/main/java/org/asamk/signal/util/IOUtils.java

index 5973d019fe1703d30d1707cd6fc0effdd512e08f..4e4c33cfaf30e339c9061069f13c5446febb4d1c 100644 (file)
@@ -61,16 +61,17 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
             } else if (content.getDataMessage().isPresent()) {
                 SignalServiceDataMessage message = content.getDataMessage().get();
 
+                byte[] groupId = getGroupId(m, message);
                 if (!message.isEndSession() &&
-                        !(message.getGroupContext().isPresent() &&
-                                message.getGroupContext().get().getGroupV1Type() != SignalServiceGroup.Type.DELIVER)) {
+                        (groupId == null
+                                || message.getGroupContext().get().getGroupV1Type() == null
+                                || message.getGroupContext().get().getGroupV1Type() == SignalServiceGroup.Type.DELIVER)) {
                     try {
                         conn.sendMessage(new Signal.MessageReceived(
                                 objectPath,
                                 message.getTimestamp(),
                                 sender.getLegacyIdentifier(),
-                                message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1().isPresent()
-                                        ? message.getGroupContext().get().getGroupV1().get().getGroupId() : new byte[0],
+                                groupId != null ? groupId : new byte[0],
                                 message.getBody().isPresent() ? message.getBody().get() : "",
                                 JsonDbusReceiveMessageHandler.getAttachments(message, m)));
                     } catch (DBusException e) {
@@ -84,6 +85,7 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
 
                     if (transcript.getDestination().isPresent() || transcript.getMessage().getGroupContext().isPresent()) {
                         SignalServiceDataMessage message = transcript.getMessage();
+                        byte[] groupId = getGroupId(m, message);
 
                         try {
                             conn.sendMessage(new Signal.SyncMessageReceived(
@@ -91,8 +93,7 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
                                     transcript.getTimestamp(),
                                     sender.getLegacyIdentifier(),
                                     transcript.getDestination().isPresent() ? transcript.getDestination().get().getLegacyIdentifier() : "",
-                                    message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1().isPresent()
-                                            ? message.getGroupContext().get().getGroupV1().get().getGroupId() : new byte[0],
+                                    groupId != null ? groupId : new byte[0],
                                     message.getBody().isPresent() ? message.getBody().get() : "",
                                     JsonDbusReceiveMessageHandler.getAttachments(message, m)));
                         } catch (DBusException e) {
@@ -104,6 +105,22 @@ public class JsonDbusReceiveMessageHandler extends JsonReceiveMessageHandler {
         }
     }
 
+    private static byte[] getGroupId(final Manager m, final SignalServiceDataMessage message) {
+        byte[] groupId;
+        if (message.getGroupContext().isPresent()) {
+            if (message.getGroupContext().get().getGroupV1().isPresent()) {
+                groupId = message.getGroupContext().get().getGroupV1().get().getGroupId();
+            } else if (message.getGroupContext().get().getGroupV2().isPresent()) {
+                groupId = m.getGroupId(message.getGroupContext().get().getGroupV2().get().getMasterKey());
+            } else {
+                groupId = null;
+            }
+        } else {
+            groupId = null;
+        }
+        return groupId;
+    }
+
     static private List<String> getAttachments(SignalServiceDataMessage message, Manager m) {
         List<String> attachments = new ArrayList<>();
         if (message.getAttachments().isPresent()) {
index 0a26443291d24245340fb1747237e3fd4bb70c28..f32303b1852934ade59df417b6337153e388a5dd 100644 (file)
@@ -275,11 +275,13 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
                     System.out.println(" - Action: " + typingMessage.getAction());
                     System.out.println(" - Timestamp: " + DateUtils.formatTimestamp(typingMessage.getTimestamp()));
                     if (typingMessage.getGroupId().isPresent()) {
+                        System.out.println(" - Group Info:");
+                        System.out.println("   Id: " + Base64.encodeBytes(typingMessage.getGroupId().get()));
                         GroupInfo group = m.getGroup(typingMessage.getGroupId().get());
                         if (group != null) {
-                            System.out.println("  Name: " + group.name);
+                            System.out.println("   Name: " + group.getTitle());
                         } else {
-                            System.out.println("  Name: <Unknown group>");
+                            System.out.println("   Name: <Unknown group>");
                         }
                     }
                 }
@@ -310,7 +312,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
                 } else {
                     GroupInfo group = m.getGroup(groupInfo.getGroupId());
                     if (group != null) {
-                        System.out.println("  Name: " + group.name);
+                        System.out.println("  Name: " + group.getTitle());
                     } else {
                         System.out.println("  Name: <Unknown group>");
                     }
@@ -327,6 +329,14 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
                 }
             } else if (groupContext.getGroupV2().isPresent()) {
                 final SignalServiceGroupV2 groupInfo = groupContext.getGroupV2().get();
+                byte[] groupId = m.getGroupId(groupInfo.getMasterKey());
+                System.out.println("  Id: " + Base64.encodeBytes(groupId));
+                GroupInfo group = m.getGroup(groupId);
+                if (group != null) {
+                    System.out.println("  Name: " + group.getTitle());
+                } else {
+                    System.out.println("  Name: <Unknown group>");
+                }
                 System.out.println("  Revision: " + groupInfo.getRevision());
                 System.out.println("  Master key length: " + groupInfo.getMasterKey().serialize().length);
                 System.out.println("  Has signed group change: " + groupInfo.hasSignedGroupChange());
@@ -376,7 +386,7 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
             final SignalServiceDataMessage.Reaction reaction = message.getReaction().get();
             System.out.println("Reaction:");
             System.out.println(" - Emoji: " + reaction.getEmoji());
-            System.out.println(" - Target author: " + reaction.getTargetAuthor().getLegacyIdentifier()); // todo resolve
+            System.out.println(" - Target author: " + m.resolveSignalServiceAddress(reaction.getTargetAuthor()).getLegacyIdentifier());
             System.out.println(" - Target timestamp: " + reaction.getTargetSentTimestamp());
             System.out.println(" - Is remove: " + reaction.isRemove());
         }
index 0baa8744dde318acdfe8cceeda90c221e0e0378f..9e13685e8b2abe5e9311ae4b7d538081dca31f8a 100644 (file)
@@ -10,16 +10,23 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 import org.whispersystems.util.Base64;
 
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 public class ListGroupsCommand implements LocalCommand {
 
-    private static void printGroup(GroupInfo group, boolean detailed, SignalServiceAddress address) {
+    private static void printGroup(Manager m, GroupInfo group, boolean detailed) {
         if (detailed) {
+            Set<String> members = group.getMembers()
+                    .stream()
+                    .map(m::resolveSignalServiceAddress)
+                    .map(SignalServiceAddress::getLegacyIdentifier)
+                    .collect(Collectors.toSet());
             System.out.println(String.format("Id: %s Name: %s  Active: %s Blocked: %b Members: %s",
-                    Base64.encodeBytes(group.groupId), group.name, group.isMember(address), group.blocked, group.getMembersE164()));
+                    Base64.encodeBytes(group.groupId), group.getTitle(), group.isMember(m.getSelfAddress()), group.isBlocked(), members));
         } else {
             System.out.println(String.format("Id: %s Name: %s  Active: %s Blocked: %b",
-                    Base64.encodeBytes(group.groupId), group.name, group.isMember(address), group.blocked));
+                    Base64.encodeBytes(group.groupId), group.getTitle(), group.isMember(m.getSelfAddress()), group.isBlocked()));
         }
     }
 
@@ -41,7 +48,7 @@ public class ListGroupsCommand implements LocalCommand {
         boolean detailed = ns.getBoolean("detailed");
 
         for (GroupInfo group : groups) {
-            printGroup(group, detailed, m.getSelfAddress());
+            printGroup(m, group, detailed);
         }
         return 0;
     }
index bffde498244c53f765c101483fc4ea2860ca5cb9..77b3bc99ccbabc49aabc2792b19cc3e807519745 100644 (file)
@@ -10,12 +10,14 @@ import org.asamk.signal.util.ErrorUtils;
 import org.freedesktop.dbus.exceptions.DBusExecutionException;
 import org.whispersystems.libsignal.util.Pair;
 import org.whispersystems.signalservice.api.messages.SendMessageResult;
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 import org.whispersystems.signalservice.api.util.InvalidNumberException;
 
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
 
 public class DbusSignalImpl implements Signal {
 
@@ -152,7 +154,7 @@ public class DbusSignalImpl implements Signal {
         if (group == null) {
             return "";
         } else {
-            return group.name;
+            return group.getTitle();
         }
     }
 
@@ -162,7 +164,7 @@ public class DbusSignalImpl implements Signal {
         if (group == null) {
             return Collections.emptyList();
         } else {
-            return new ArrayList<>(group.getMembersE164());
+            return group.getMembers().stream().map(m::resolveSignalServiceAddress).map(SignalServiceAddress::getLegacyIdentifier).collect(Collectors.toList());
         }
     }
 
index 83a3926a6f7dc55032857b9e32ce8590491b4948..07c8b583d89675ee8dd7c6f13437b503dcbeb198 100644 (file)
@@ -21,7 +21,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.asamk.signal.storage.SignalAccount;
 import org.asamk.signal.storage.contacts.ContactInfo;
 import org.asamk.signal.storage.groups.GroupInfo;
-import org.asamk.signal.storage.groups.JsonGroupStore;
+import org.asamk.signal.storage.groups.GroupInfoV1;
+import org.asamk.signal.storage.groups.GroupInfoV2;
 import org.asamk.signal.storage.profiles.SignalProfile;
 import org.asamk.signal.storage.profiles.SignalProfileEntry;
 import org.asamk.signal.storage.protocol.JsonIdentityKeyStore;
@@ -39,7 +40,13 @@ import org.signal.libsignal.metadata.ProtocolNoSessionException;
 import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException;
 import org.signal.libsignal.metadata.SelfSendException;
 import org.signal.libsignal.metadata.certificate.InvalidCertificateException;
+import org.signal.storageservice.protos.groups.local.DecryptedGroup;
+import org.signal.storageservice.protos.groups.local.DecryptedMember;
 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.profiles.ClientZkProfileOperations;
 import org.signal.zkgroup.profiles.ProfileKey;
 import org.whispersystems.libsignal.IdentityKey;
@@ -67,7 +74,10 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
 import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
 import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
 import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
+import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
+import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
 import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
+import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
 import org.whispersystems.signalservice.api.messages.SendMessageResult;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
 import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@@ -77,6 +87,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceContent;
 import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
 import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
 import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
+import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
 import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
 import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload;
 import org.whispersystems.signalservice.api.messages.SignalServiceStickerManifestUpload.StickerInfo;
@@ -130,6 +141,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
@@ -155,6 +167,7 @@ public class Manager implements Closeable {
     private final SignalAccount account;
     private final PathConfig pathConfig;
     private SignalServiceAccountManager accountManager;
+    private GroupsV2Api groupsV2Api;
     private SignalServiceMessagePipe messagePipe = null;
     private SignalServiceMessagePipe unidentifiedMessagePipe = null;
     private final boolean discoverableByPhoneNumber = true;
@@ -165,6 +178,7 @@ public class Manager implements Closeable {
         this.serviceConfiguration = serviceConfiguration;
         this.userAgent = userAgent;
         this.accountManager = createSignalServiceAccountManager();
+        this.groupsV2Api = accountManager.getGroupsV2Api();
 
         this.account.setResolver(this::resolveSignalServiceAddress);
     }
@@ -178,12 +192,10 @@ public class Manager implements Closeable {
     }
 
     private SignalServiceAccountManager createSignalServiceAccountManager() {
-        GroupsV2Operations groupsV2Operations;
-        try {
-            groupsV2Operations = new GroupsV2Operations(ClientZkOperations.create(serviceConfiguration));
-        } catch (Throwable ignored) {
-            groupsV2Operations = null;
-        }
+        GroupsV2Operations groupsV2Operations = capabilities.isGv2()
+                ? new GroupsV2Operations(ClientZkOperations.create(serviceConfiguration))
+                : null;
+
         return new SignalServiceAccountManager(serviceConfiguration,
                 new DynamicCredentialsProvider(account.getUuid(), account.getUsername(), account.getPassword(), null, account.getDeviceId()),
                 userAgent,
@@ -236,29 +248,12 @@ public class Manager implements Closeable {
         Manager m = new Manager(account, pathConfig, serviceConfiguration, userAgent);
 
         m.migrateLegacyConfigs();
+        m.updateAccountAttributes();
 
         return m;
     }
 
     private void migrateLegacyConfigs() {
-        // Copy group avatars that were previously stored in the attachments folder
-        // to the new avatar folder
-        if (JsonGroupStore.groupsWithLegacyAvatarId.size() > 0) {
-            for (GroupInfo g : JsonGroupStore.groupsWithLegacyAvatarId) {
-                File avatarFile = getGroupAvatarFile(g.groupId);
-                File attachmentFile = getAttachmentFile(new SignalServiceAttachmentRemoteId(g.getAvatarId()));
-                if (!avatarFile.exists() && attachmentFile.exists()) {
-                    try {
-                        IOUtils.createPrivateDirectories(pathConfig.getAvatarsPath());
-                        Files.copy(attachmentFile.toPath(), avatarFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
-                    } catch (Exception e) {
-                        // Ignore
-                    }
-                }
-            }
-            JsonGroupStore.groupsWithLegacyAvatarId.clear();
-            account.save();
-        }
         if (account.getProfileKey() == null) {
             // Old config file, creating new profile key
             account.setProfileKey(KeyUtils.createProfileKey());
@@ -304,6 +299,7 @@ public class Manager implements Closeable {
         // Resetting UUID, because registering doesn't work otherwise
         account.setUuid(null);
         accountManager = createSignalServiceAccountManager();
+        this.groupsV2Api = accountManager.getGroupsV2Api();
 
         if (voiceVerification) {
             accountManager.requestVoiceVerificationCode(Locale.getDefault(), Optional.fromNullable(captcha), Optional.absent());
@@ -435,14 +431,16 @@ public class Manager implements Closeable {
     }
 
     private SignalServiceMessageReceiver getMessageReceiver() {
-        // TODO implement ZkGroup support
-        final ClientZkProfileOperations clientZkProfileOperations = null;
+        final ClientZkProfileOperations clientZkProfileOperations = capabilities.isGv2()
+                ? ClientZkOperations.create(serviceConfiguration).getProfileOperations()
+                : null;
         return new SignalServiceMessageReceiver(serviceConfiguration, account.getUuid(), account.getUsername(), account.getPassword(), account.getDeviceId(), account.getSignalingKey(), userAgent, null, timer, clientZkProfileOperations);
     }
 
     private SignalServiceMessageSender getMessageSender() {
-        // TODO implement ZkGroup support
-        final ClientZkProfileOperations clientZkProfileOperations = null;
+        final ClientZkProfileOperations clientZkProfileOperations = capabilities.isGv2()
+                ? ClientZkOperations.create(serviceConfiguration).getProfileOperations()
+                : null;
         final ExecutorService executor = null;
         return new SignalServiceMessageSender(serviceConfiguration, account.getUuid(), account.getUsername(), account.getPassword(),
                 account.getDeviceId(), account.getSignalProtocolStore(), userAgent, account.isMultiDevice(), Optional.fromNullable(messagePipe), Optional.fromNullable(unidentifiedMessagePipe), Optional.absent(), clientZkProfileOperations, executor, ServiceConfig.MAX_ENVELOPE_SIZE);
@@ -527,7 +525,7 @@ public class Manager implements Closeable {
             throw new GroupNotFoundException(groupId);
         }
         if (!g.isMember(account.getSelfAddress())) {
-            throw new NotAGroupMemberException(groupId, g.name);
+            throw new NotAGroupMemberException(groupId, g.getTitle());
         }
         return g;
     }
@@ -546,33 +544,38 @@ public class Manager implements Closeable {
         if (attachments != null) {
             messageBuilder.withAttachments(Utils.getSignalServiceAttachments(attachments));
         }
-        if (groupId != null) {
-            SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.DELIVER)
-                    .withId(groupId)
-                    .build();
-            messageBuilder.asGroupMessage(group);
-        }
 
         final GroupInfo g = getGroupForSending(groupId);
 
-        messageBuilder.withExpiration(g.messageExpirationTime);
+        setGroupContext(messageBuilder, g);
+        messageBuilder.withExpiration(g.getMessageExpirationTime());
 
         return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress()));
     }
 
+    private void setGroupContext(final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo groupInfo) {
+        if (groupInfo instanceof GroupInfoV1) {
+            SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.DELIVER)
+                    .withId(groupInfo.groupId)
+                    .build();
+            messageBuilder.asGroupMessage(group);
+        } else {
+            final GroupInfoV2 groupInfoV2 = (GroupInfoV2) groupInfo;
+            SignalServiceGroupV2 group = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
+                    .withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().getRevision())
+                    .build();
+            messageBuilder.asGroupMessage(group);
+        }
+    }
+
     public Pair<Long, List<SendMessageResult>> sendGroupMessageReaction(String emoji, boolean remove, String targetAuthor,
                                                                         long targetSentTimestamp, byte[] groupId)
             throws IOException, InvalidNumberException, NotAGroupMemberException, GroupNotFoundException {
         SignalServiceDataMessage.Reaction reaction = new SignalServiceDataMessage.Reaction(emoji, remove, canonicalizeAndResolveSignalServiceAddress(targetAuthor), targetSentTimestamp);
         final SignalServiceDataMessage.Builder messageBuilder = SignalServiceDataMessage.newBuilder()
                 .withReaction(reaction);
-        if (groupId != null) {
-            SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.DELIVER)
-                    .withId(groupId)
-                    .build();
-            messageBuilder.asGroupMessage(group);
-        }
         final GroupInfo g = getGroupForSending(groupId);
+        setGroupContext(messageBuilder, g);
         return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress()));
     }
 
@@ -585,20 +588,29 @@ public class Manager implements Closeable {
                 .asGroupMessage(group);
 
         final GroupInfo g = getGroupForSending(groupId);
-        g.removeMember(account.getSelfAddress());
-        account.getGroupStore().updateGroup(g);
+        if (g instanceof GroupInfoV1) {
+            GroupInfoV1 groupInfoV1 = (GroupInfoV1) g;
+            groupInfoV1.removeMember(account.getSelfAddress());
+            account.getGroupStore().updateGroup(groupInfoV1);
+        } else {
+            throw new RuntimeException("TODO Not implemented!");
+        }
 
         return sendMessage(messageBuilder, g.getMembersWithout(account.getSelfAddress()));
     }
 
     private Pair<byte[], List<SendMessageResult>> sendUpdateGroupMessage(byte[] groupId, String name, Collection<SignalServiceAddress> members, String avatarFile) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException {
-        GroupInfo g;
+        GroupInfoV1 g;
         if (groupId == null) {
             // Create new group
-            g = new GroupInfo(KeyUtils.createGroupId());
+            g = new GroupInfoV1(KeyUtils.createGroupId());
             g.addMembers(Collections.singleton(account.getSelfAddress()));
         } else {
-            g = getGroupForSending(groupId);
+            GroupInfo group = getGroupForSending(groupId);
+            if (!(group instanceof GroupInfoV1)) {
+                throw new RuntimeException("TODO Not implemented!");
+            }
+            g = (GroupInfoV1) group;
         }
 
         if (name != null) {
@@ -641,7 +653,12 @@ public class Manager implements Closeable {
     }
 
     Pair<Long, List<SendMessageResult>> sendUpdateGroupMessage(byte[] groupId, SignalServiceAddress recipient) throws IOException, NotAGroupMemberException, GroupNotFoundException, AttachmentInvalidException {
-        GroupInfo g = getGroupForSending(groupId);
+        GroupInfoV1 g;
+        GroupInfo group = getGroupForSending(groupId);
+        if (!(group instanceof GroupInfoV1)) {
+            throw new RuntimeException("TODO Not implemented!");
+        }
+        g = (GroupInfoV1) group;
 
         if (!g.isMember(recipient)) {
             throw new NotAGroupMemberException(groupId, g.name);
@@ -653,7 +670,7 @@ public class Manager implements Closeable {
         return sendMessage(messageBuilder, Collections.singleton(recipient));
     }
 
-    private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfo g) throws AttachmentInvalidException {
+    private SignalServiceDataMessage.Builder getGroupUpdateMessageBuilder(GroupInfoV1 g) throws AttachmentInvalidException {
         SignalServiceGroup.Builder group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
                 .withId(g.groupId)
                 .withName(g.name)
@@ -780,7 +797,7 @@ public class Manager implements Closeable {
             throw new GroupNotFoundException(groupId);
         }
 
-        group.blocked = blocked;
+        group.setBlocked(blocked);
         account.getGroupStore().updateGroup(group);
         account.save();
     }
@@ -831,8 +848,13 @@ public class Manager implements Closeable {
      */
     public void setExpirationTimer(byte[] groupId, int messageExpirationTimer) {
         GroupInfo g = account.getGroupStore().getGroup(groupId);
-        g.messageExpirationTime = messageExpirationTimer;
-        account.getGroupStore().updateGroup(g);
+        if (g instanceof GroupInfoV1) {
+            GroupInfoV1 groupInfoV1 = (GroupInfoV1) g;
+            groupInfoV1.messageExpirationTime = messageExpirationTimer;
+            account.getGroupStore().updateGroup(groupInfoV1);
+        } else {
+            throw new RuntimeException("TODO Not implemented!");
+        }
     }
 
     /**
@@ -1101,6 +1123,7 @@ public class Manager implements Closeable {
 
     private Pair<Long, List<SendMessageResult>> sendMessage(SignalServiceDataMessage.Builder messageBuilder, Collection<SignalServiceAddress> recipients)
             throws IOException {
+        recipients = recipients.stream().map(this::resolveSignalServiceAddress).collect(Collectors.toSet());
         final long timestamp = System.currentTimeMillis();
         messageBuilder.withTimestamp(timestamp);
         if (messagePipe == null) {
@@ -1211,57 +1234,114 @@ public class Manager implements Closeable {
         account.getSignalProtocolStore().deleteAllSessions(source);
     }
 
+    private static int currentTimeDays() {
+        return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis());
+    }
+
+    private GroupsV2AuthorizationString getGroupAuthForToday(final GroupSecretParams groupSecretParams) throws IOException, VerificationFailedException {
+        final int today = currentTimeDays();
+        // Returns credentials for the next 7 days
+        final HashMap<Integer, AuthCredentialResponse> credentials = groupsV2Api.getCredentials(today);
+        // TODO cache credentials until they expire
+        AuthCredentialResponse authCredentialResponse = credentials.get(today);
+        return groupsV2Api.getGroupsV2AuthorizationString(account.getUuid(), today, groupSecretParams, authCredentialResponse);
+    }
+
     private List<HandleAction> handleSignalServiceDataMessage(SignalServiceDataMessage message, boolean isSync, SignalServiceAddress source, SignalServiceAddress destination, boolean ignoreAttachments) {
         List<HandleAction> actions = new ArrayList<>();
-        if (message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1().isPresent()) {
-            SignalServiceGroup groupInfo = message.getGroupContext().get().getGroupV1().get();
-            GroupInfo group = account.getGroupStore().getGroup(groupInfo.getGroupId());
-            switch (groupInfo.getType()) {
-                case UPDATE:
-                    if (group == null) {
-                        group = new GroupInfo(groupInfo.getGroupId());
-                    }
+        if (message.getGroupContext().isPresent()) {
+            if (message.getGroupContext().get().getGroupV1().isPresent()) {
+                SignalServiceGroup groupInfo = message.getGroupContext().get().getGroupV1().get();
+                GroupInfo group = account.getGroupStore().getGroup(groupInfo.getGroupId());
+                if (group == null || group instanceof GroupInfoV1) {
+                    GroupInfoV1 groupV1 = (GroupInfoV1) group;
+                    switch (groupInfo.getType()) {
+                        case UPDATE: {
+                            if (groupV1 == null) {
+                                groupV1 = new GroupInfoV1(groupInfo.getGroupId());
+                            }
 
-                    if (groupInfo.getAvatar().isPresent()) {
-                        SignalServiceAttachment avatar = groupInfo.getAvatar().get();
-                        if (avatar.isPointer()) {
-                            try {
-                                retrieveGroupAvatarAttachment(avatar.asPointer(), group.groupId);
-                            } catch (IOException | InvalidMessageException | MissingConfigurationException e) {
-                                System.err.println("Failed to retrieve group avatar (" + avatar.asPointer().getRemoteId() + "): " + e.getMessage());
+                            if (groupInfo.getAvatar().isPresent()) {
+                                SignalServiceAttachment avatar = groupInfo.getAvatar().get();
+                                if (avatar.isPointer()) {
+                                    try {
+                                        retrieveGroupAvatarAttachment(avatar.asPointer(), groupV1.groupId);
+                                    } catch (IOException | InvalidMessageException | MissingConfigurationException e) {
+                                        System.err.println("Failed to retrieve group avatar (" + avatar.asPointer().getRemoteId() + "): " + e.getMessage());
+                                    }
+                                }
                             }
-                        }
-                    }
 
-                    if (groupInfo.getName().isPresent()) {
-                        group.name = groupInfo.getName().get();
-                    }
+                            if (groupInfo.getName().isPresent()) {
+                                groupV1.name = groupInfo.getName().get();
+                            }
 
-                    if (groupInfo.getMembers().isPresent()) {
-                        group.addMembers(groupInfo.getMembers().get()
-                                .stream()
-                                .map(this::resolveSignalServiceAddress)
-                                .collect(Collectors.toSet()));
-                    }
+                            if (groupInfo.getMembers().isPresent()) {
+                                groupV1.addMembers(groupInfo.getMembers().get()
+                                        .stream()
+                                        .map(this::resolveSignalServiceAddress)
+                                        .collect(Collectors.toSet()));
+                            }
 
-                    account.getGroupStore().updateGroup(group);
-                    break;
-                case DELIVER:
-                    if (group == null && !isSync) {
-                        actions.add(new SendGroupInfoRequestAction(source, groupInfo.getGroupId()));
-                    }
-                    break;
-                case QUIT:
-                    if (group != null) {
-                        group.removeMember(source);
-                        account.getGroupStore().updateGroup(group);
+                            account.getGroupStore().updateGroup(groupV1);
+                            break;
+                        }
+                        case DELIVER:
+                            if (groupV1 == null && !isSync) {
+                                actions.add(new SendGroupInfoRequestAction(source, groupInfo.getGroupId()));
+                            }
+                            break;
+                        case QUIT: {
+                            if (groupV1 != null) {
+                                groupV1.removeMember(source);
+                                account.getGroupStore().updateGroup(groupV1);
+                            }
+                            break;
+                        }
+                        case REQUEST_INFO:
+                            if (groupV1 != null && !isSync) {
+                                actions.add(new SendGroupUpdateAction(source, groupV1.groupId));
+                            }
+                            break;
                     }
-                    break;
-                case REQUEST_INFO:
-                    if (group != null && !isSync) {
-                        actions.add(new SendGroupUpdateAction(source, group.groupId));
+                } else {
+                    System.err.println("Received a group v1 message for a v2 group: " + group.getTitle());
+                }
+            }
+            if (message.getGroupContext().get().getGroupV2().isPresent()) {
+                final SignalServiceGroupV2 groupContext = message.getGroupContext().get().getGroupV2().get();
+                final GroupMasterKey groupMasterKey = groupContext.getMasterKey();
+
+                final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+
+                byte[] groupId = groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
+                GroupInfo groupInfo = account.getGroupStore().getGroup(groupId);
+                if (groupInfo instanceof GroupInfoV1) {
+                    // TODO upgrade group
+                } else if (groupInfo == null || groupInfo instanceof GroupInfoV2) {
+                    GroupInfoV2 groupInfoV2 = groupInfo == null
+                            ? new GroupInfoV2(groupId, groupMasterKey)
+                            : (GroupInfoV2) groupInfo;
+
+                    if (groupInfoV2.getGroup() == null || groupInfoV2.getGroup().getRevision() < groupContext.getRevision()) {
+                        // TODO check if revision is only 1 behind and a signedGroupChange is available
+                        try {
+                            final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
+                            final DecryptedGroup group = groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
+                            groupInfoV2.setGroup(group);
+                            for (DecryptedMember member : group.getMembersList()) {
+                                final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(member.getUuid().toByteArray()), null));
+                                try {
+                                    account.getProfileStore().storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray()));
+                                } catch (InvalidInputException ignored) {
+                                }
+                            }
+                        } catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
+                            System.err.println("Failed to retrieve Group V2 info, ignoring ...");
+                        }
+                        account.getGroupStore().updateGroup(groupInfoV2);
                     }
-                    break;
+                }
             }
         }
         final SignalServiceAddress conversationPartnerAddress = isSync ? destination : source;
@@ -1269,15 +1349,18 @@ public class Manager implements Closeable {
             handleEndSession(conversationPartnerAddress);
         }
         if (message.isExpirationUpdate() || message.getBody().isPresent()) {
-            if (message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1().isPresent()) {
-                SignalServiceGroup groupInfo = message.getGroupContext().get().getGroupV1().get();
-                GroupInfo group = account.getGroupStore().getGroup(groupInfo.getGroupId());
-                if (group == null) {
-                    group = new GroupInfo(groupInfo.getGroupId());
-                }
-                if (group.messageExpirationTime != message.getExpiresInSeconds()) {
-                    group.messageExpirationTime = message.getExpiresInSeconds();
-                    account.getGroupStore().updateGroup(group);
+            if (message.getGroupContext().isPresent()) {
+                if (message.getGroupContext().get().getGroupV1().isPresent()) {
+                    SignalServiceGroup groupInfo = message.getGroupContext().get().getGroupV1().get();
+                    GroupInfoV1 group = account.getGroupStore().getOrCreateGroupV1(groupInfo.getGroupId());
+                    if (group != null) {
+                        if (group.messageExpirationTime != message.getExpiresInSeconds()) {
+                            group.messageExpirationTime = message.getExpiresInSeconds();
+                            account.getGroupStore().updateGroup(group);
+                        }
+                    }
+                } else if (message.getGroupContext().get().getGroupV2().isPresent()) {
+                    // disappearing message timer already stored in the DecryptedGroup
                 }
             } else {
                 ContactInfo contact = account.getContactStore().getContact(conversationPartnerAddress);
@@ -1519,7 +1602,7 @@ public class Manager implements Closeable {
             if (message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1().isPresent()) {
                 SignalServiceGroup groupInfo = message.getGroupContext().get().getGroupV1().get();
                 GroupInfo group = getGroup(groupInfo.getGroupId());
-                return groupInfo.getType() == SignalServiceGroup.Type.DELIVER && group != null && group.blocked;
+                return groupInfo.getType() == SignalServiceGroup.Type.DELIVER && group != null && group.isBlocked();
             }
         }
         return false;
@@ -1574,34 +1657,33 @@ public class Manager implements Closeable {
                             DeviceGroupsInputStream s = new DeviceGroupsInputStream(attachmentAsStream);
                             DeviceGroup g;
                             while ((g = s.read()) != null) {
-                                GroupInfo syncGroup = account.getGroupStore().getGroup(g.getId());
-                                if (syncGroup == null) {
-                                    syncGroup = new GroupInfo(g.getId());
-                                }
-                                if (g.getName().isPresent()) {
-                                    syncGroup.name = g.getName().get();
+                                GroupInfoV1 syncGroup = account.getGroupStore().getOrCreateGroupV1(g.getId());
+                                if (syncGroup != null) {
+                                    if (g.getName().isPresent()) {
+                                        syncGroup.name = g.getName().get();
+                                    }
+                                    syncGroup.addMembers(g.getMembers()
+                                            .stream()
+                                            .map(this::resolveSignalServiceAddress)
+                                            .collect(Collectors.toSet()));
+                                    if (!g.isActive()) {
+                                        syncGroup.removeMember(account.getSelfAddress());
+                                    } else {
+                                        // Add ourself to the member set as it's marked as active
+                                        syncGroup.addMembers(Collections.singleton(account.getSelfAddress()));
+                                    }
+                                    syncGroup.blocked = g.isBlocked();
+                                    if (g.getColor().isPresent()) {
+                                        syncGroup.color = g.getColor().get();
+                                    }
+
+                                    if (g.getAvatar().isPresent()) {
+                                        retrieveGroupAvatarAttachment(g.getAvatar().get(), syncGroup.groupId);
+                                    }
+                                    syncGroup.inboxPosition = g.getInboxPosition().orNull();
+                                    syncGroup.archived = g.isArchived();
+                                    account.getGroupStore().updateGroup(syncGroup);
                                 }
-                                syncGroup.addMembers(g.getMembers()
-                                        .stream()
-                                        .map(this::resolveSignalServiceAddress)
-                                        .collect(Collectors.toSet()));
-                                if (!g.isActive()) {
-                                    syncGroup.removeMember(account.getSelfAddress());
-                                } else {
-                                    // Add ourself to the member set as it's marked as active
-                                    syncGroup.addMembers(Collections.singleton(account.getSelfAddress()));
-                                }
-                                syncGroup.blocked = g.isBlocked();
-                                if (g.getColor().isPresent()) {
-                                    syncGroup.color = g.getColor().get();
-                                }
-
-                                if (g.getAvatar().isPresent()) {
-                                    retrieveGroupAvatarAttachment(g.getAvatar().get(), syncGroup.groupId);
-                                }
-                                syncGroup.inboxPosition = g.getInboxPosition().orNull();
-                                syncGroup.archived = g.isArchived();
-                                account.getGroupStore().updateGroup(syncGroup);
                             }
                         }
                     } catch (Exception e) {
@@ -1800,10 +1882,13 @@ public class Manager implements Closeable {
             try (OutputStream fos = new FileOutputStream(groupsFile)) {
                 DeviceGroupsOutputStream out = new DeviceGroupsOutputStream(fos);
                 for (GroupInfo record : account.getGroupStore().getGroups()) {
-                    out.write(new DeviceGroup(record.groupId, Optional.fromNullable(record.name),
-                            new ArrayList<>(record.getMembers()), createGroupAvatarAttachment(record.groupId),
-                            record.isMember(account.getSelfAddress()), Optional.of(record.messageExpirationTime),
-                            Optional.fromNullable(record.color), record.blocked, Optional.fromNullable(record.inboxPosition), record.archived));
+                    if (record instanceof GroupInfoV1) {
+                        GroupInfoV1 groupInfo = (GroupInfoV1) record;
+                        out.write(new DeviceGroup(groupInfo.groupId, Optional.fromNullable(groupInfo.name),
+                                new ArrayList<>(groupInfo.getMembers()), createGroupAvatarAttachment(groupInfo.groupId),
+                                groupInfo.isMember(account.getSelfAddress()), Optional.of(groupInfo.messageExpirationTime),
+                                Optional.fromNullable(groupInfo.color), groupInfo.blocked, Optional.fromNullable(groupInfo.inboxPosition), groupInfo.archived));
+                    }
                 }
             }
 
@@ -1887,7 +1972,7 @@ public class Manager implements Closeable {
         }
         List<byte[]> groupIds = new ArrayList<>();
         for (GroupInfo record : account.getGroupStore().getGroups()) {
-            if (record.blocked) {
+            if (record.isBlocked()) {
                 groupIds.add(record.groupId);
             }
         }
@@ -1911,6 +1996,11 @@ public class Manager implements Closeable {
         return account.getGroupStore().getGroup(groupId);
     }
 
+    public byte[] getGroupId(GroupMasterKey groupMasterKey) {
+        final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
+        return groupSecretParams.getPublicParams().getGroupIdentifier().serialize();
+    }
+
     public List<JsonIdentityKeyStore.Identity> getIdentities() {
         return account.getSignalProtocolStore().getIdentities();
     }
index 4ea41734ecf93cd977c29dd2614575033060b502..4498fc6531d83040055240d8dcbec763aa91910d 100644 (file)
@@ -1,5 +1,6 @@
 package org.asamk.signal.manager;
 
+import org.signal.zkgroup.ServerPublicParams;
 import org.whispersystems.libsignal.util.guava.Optional;
 import org.whispersystems.signalservice.api.account.AccountAttributes;
 import org.whispersystems.signalservice.api.push.TrustStore;
@@ -13,7 +14,6 @@ import org.whispersystems.util.Base64;
 
 import java.io.IOException;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -39,8 +39,26 @@ public class ServiceConfig {
     private final static Optional<Dns> dns = Optional.absent();
 
     private final static String zkGroupServerPublicParamsHex = "AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=";
+    private final static byte[] zkGroupServerPublicParams;
 
-    static final AccountAttributes.Capabilities capabilities = new AccountAttributes.Capabilities(false, false, false, false);
+    static final AccountAttributes.Capabilities capabilities;
+
+    static {
+        try {
+            zkGroupServerPublicParams = Base64.decode(zkGroupServerPublicParamsHex);
+        } catch (IOException e) {
+            throw new AssertionError(e);
+        }
+
+        boolean zkGroupAvailable;
+        try {
+            new ServerPublicParams(zkGroupServerPublicParams);
+            zkGroupAvailable = true;
+        } catch (Throwable ignored) {
+            zkGroupAvailable = false;
+        }
+        capabilities = new AccountAttributes.Capabilities(false, zkGroupAvailable, false, false);
+    }
 
     public static SignalServiceConfiguration createDefaultServiceConfiguration(String userAgent) {
         final Interceptor userAgentInterceptor = chain ->
@@ -50,13 +68,6 @@ public class ServiceConfig {
 
         final List<Interceptor> interceptors = Collections.singletonList(userAgentInterceptor);
 
-        final byte[] zkGroupServerPublicParams;
-        try {
-            zkGroupServerPublicParams = Base64.decode(zkGroupServerPublicParamsHex);
-        } catch (IOException e) {
-            throw new AssertionError(e);
-        }
-
         return new SignalServiceConfiguration(
                 new SignalServiceUrl[]{new SignalServiceUrl(URL, TRUST_STORE)},
                 makeSignalCdnUrlMapFor(new SignalCdnUrl[]{new SignalCdnUrl(CDN_URL, TRUST_STORE)}, new SignalCdnUrl[]{new SignalCdnUrl(CDN2_URL, TRUST_STORE)}),
@@ -70,10 +81,7 @@ public class ServiceConfig {
     }
 
     private static Map<Integer, SignalCdnUrl[]> makeSignalCdnUrlMapFor(SignalCdnUrl[] cdn0Urls, SignalCdnUrl[] cdn2Urls) {
-        Map<Integer, SignalCdnUrl[]> result = new HashMap<>();
-        result.put(0, cdn0Urls);
-        result.put(2, cdn2Urls);
-        return Collections.unmodifiableMap(result);
+        return Map.of(0, cdn0Urls, 2, cdn2Urls);
     }
 
     private ServiceConfig() {
index 6043d803407b82457359ed59e5044797605184e4..d3c2506dc578f07e488fa6c53b9e2a8a340a763f 100644 (file)
@@ -13,6 +13,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.asamk.signal.storage.contacts.ContactInfo;
 import org.asamk.signal.storage.contacts.JsonContactsStore;
 import org.asamk.signal.storage.groups.GroupInfo;
+import org.asamk.signal.storage.groups.GroupInfoV1;
 import org.asamk.signal.storage.groups.JsonGroupStore;
 import org.asamk.signal.storage.profiles.ProfileStore;
 import org.asamk.signal.storage.protocol.JsonIdentityKeyStore;
@@ -87,7 +88,7 @@ public class SignalAccount implements Closeable {
         final Pair<FileChannel, FileLock> pair = openFileChannel(fileName);
         try {
             SignalAccount account = new SignalAccount(pair.first(), pair.second());
-            account.load();
+            account.load(dataPath);
             return account;
         } catch (Throwable e) {
             pair.second().close();
@@ -109,7 +110,7 @@ public class SignalAccount implements Closeable {
         account.username = username;
         account.profileKey = profileKey;
         account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
-        account.groupStore = new JsonGroupStore();
+        account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
         account.contactStore = new JsonContactsStore();
         account.recipientStore = new RecipientStore();
         account.profileStore = new ProfileStore();
@@ -135,7 +136,7 @@ public class SignalAccount implements Closeable {
         account.deviceId = deviceId;
         account.signalingKey = signalingKey;
         account.signalProtocolStore = new JsonSignalProtocolStore(identityKey, registrationId);
-        account.groupStore = new JsonGroupStore();
+        account.groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
         account.contactStore = new JsonContactsStore();
         account.recipientStore = new RecipientStore();
         account.profileStore = new ProfileStore();
@@ -149,6 +150,10 @@ public class SignalAccount implements Closeable {
         return dataPath + "/" + username;
     }
 
+    private static File getGroupCachePath(String dataPath, String username) {
+        return new File(new File(dataPath, username + ".d"), "group-cache");
+    }
+
     public static boolean userExists(String dataPath, String username) {
         if (username == null) {
             return false;
@@ -157,7 +162,7 @@ public class SignalAccount implements Closeable {
         return !(!f.exists() || f.isDirectory());
     }
 
-    private void load() throws IOException {
+    private void load(String dataPath) throws IOException {
         JsonNode rootNode;
         synchronized (fileChannel) {
             fileChannel.position(0);
@@ -209,9 +214,10 @@ public class SignalAccount implements Closeable {
         JsonNode groupStoreNode = rootNode.get("groupStore");
         if (groupStoreNode != null) {
             groupStore = jsonProcessor.convertValue(groupStoreNode, JsonGroupStore.class);
+            groupStore.groupCachePath = getGroupCachePath(dataPath, username);
         }
         if (groupStore == null) {
-            groupStore = new JsonGroupStore();
+            groupStore = new JsonGroupStore(getGroupCachePath(dataPath, username));
         }
 
         JsonNode contactStoreNode = rootNode.get("contactStore");
@@ -236,9 +242,12 @@ public class SignalAccount implements Closeable {
             }
 
             for (GroupInfo group : groupStore.getGroups()) {
-                group.members = group.members.stream()
-                        .map(m -> recipientStore.resolveServiceAddress(m))
-                        .collect(Collectors.toSet());
+                if (group instanceof GroupInfoV1) {
+                    GroupInfoV1 groupInfoV1 = (GroupInfoV1) group;
+                    groupInfoV1.members = groupInfoV1.members.stream()
+                            .map(m -> recipientStore.resolveServiceAddress(m))
+                            .collect(Collectors.toSet());
+                }
             }
 
             for (SessionInfo session : signalProtocolStore.getSessions()) {
@@ -273,8 +282,8 @@ public class SignalAccount implements Closeable {
                         contactStore.updateContact(contactInfo);
                     } else {
                         GroupInfo groupInfo = groupStore.getGroup(Base64.decode(thread.id));
-                        if (groupInfo != null) {
-                            groupInfo.messageExpirationTime = thread.messageExpirationTime;
+                        if (groupInfo instanceof GroupInfoV1) {
+                            ((GroupInfoV1) groupInfo).messageExpirationTime = thread.messageExpirationTime;
                             groupStore.updateGroup(groupInfo);
                         }
                     }
index 4b0adcd0a7dafa283769dc1b725734d8dd575077..db4f46908f0de50fbce75273d15b94f86317892a 100644 (file)
@@ -2,98 +2,40 @@ package org.asamk.signal.storage.groups;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.JsonParser;
-import com.fasterxml.jackson.databind.DeserializationContext;
-import com.fasterxml.jackson.databind.JsonDeserializer;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.JsonSerializer;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializerProvider;
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
-import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 
 import org.whispersystems.signalservice.api.push.SignalServiceAddress;
 
-import java.io.IOException;
-import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
-import java.util.UUID;
 
-public class GroupInfo {
-
-    private static final ObjectMapper jsonProcessor = new ObjectMapper();
+public abstract class GroupInfo {
 
     @JsonProperty
     public final byte[] groupId;
 
-    @JsonProperty
-    public String name;
-
-    @JsonProperty
-    @JsonDeserialize(using = MembersDeserializer.class)
-    @JsonSerialize(using = MembersSerializer.class)
-    public Set<SignalServiceAddress> members = new HashSet<>();
-    @JsonProperty
-    public String color;
-    @JsonProperty(defaultValue = "0")
-    public int messageExpirationTime;
-    @JsonProperty(defaultValue = "false")
-    public boolean blocked;
-    @JsonProperty
-    public Integer inboxPosition;
-    @JsonProperty(defaultValue = "false")
-    public boolean archived;
-
-    private long avatarId;
-
-    @JsonProperty
-    @JsonIgnore
-    private boolean active;
-
     public GroupInfo(byte[] groupId) {
         this.groupId = groupId;
     }
 
-    public GroupInfo(@JsonProperty("groupId") byte[] groupId, @JsonProperty("name") String name, @JsonProperty("members") Collection<SignalServiceAddress> members, @JsonProperty("avatarId") long avatarId, @JsonProperty("color") String color, @JsonProperty("blocked") boolean blocked, @JsonProperty("inboxPosition") Integer inboxPosition, @JsonProperty("archived") boolean archived, @JsonProperty("messageExpirationTime") int messageExpirationTime) {
-        this.groupId = groupId;
-        this.name = name;
-        this.members.addAll(members);
-        this.avatarId = avatarId;
-        this.color = color;
-        this.blocked = blocked;
-        this.inboxPosition = inboxPosition;
-        this.archived = archived;
-        this.messageExpirationTime = messageExpirationTime;
-    }
+    @JsonIgnore
+    public abstract String getTitle();
 
     @JsonIgnore
-    public long getAvatarId() {
-        return avatarId;
-    }
+    public abstract Set<SignalServiceAddress> getMembers();
 
     @JsonIgnore
-    public Set<SignalServiceAddress> getMembers() {
-        return members;
-    }
+    public abstract boolean isBlocked();
 
     @JsonIgnore
-    public Set<String> getMembersE164() {
-        Set<String> membersE164 = new HashSet<>();
-        for (SignalServiceAddress member : members) {
-            if (!member.getNumber().isPresent()) {
-                continue;
-            }
-            membersE164.add(member.getNumber().get());
-        }
-        return membersE164;
-    }
+    public abstract void setBlocked(boolean blocked);
+
+    @JsonIgnore
+    public abstract int getMessageExpirationTime();
 
     @JsonIgnore
     public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
-        Set<SignalServiceAddress> members = new HashSet<>(this.members.size());
-        for (SignalServiceAddress member : this.members) {
+        Set<SignalServiceAddress> members = new HashSet<>();
+        for (SignalServiceAddress member : getMembers()) {
             if (!member.matches(address)) {
                 members.add(member);
             }
@@ -101,85 +43,13 @@ public class GroupInfo {
         return members;
     }
 
-    public void addMembers(Collection<SignalServiceAddress> addresses) {
-        for (SignalServiceAddress address : addresses) {
-            if (this.members.contains(address)) {
-                continue;
-            }
-            removeMember(address);
-            this.members.add(address);
-        }
-    }
-
-    public void removeMember(SignalServiceAddress address) {
-        this.members.removeIf(member -> member.matches(address));
-    }
-
     @JsonIgnore
     public boolean isMember(SignalServiceAddress address) {
-        for (SignalServiceAddress member : this.members) {
+        for (SignalServiceAddress member : getMembers()) {
             if (member.matches(address)) {
                 return true;
             }
         }
         return false;
     }
-
-    private static final class JsonSignalServiceAddress {
-
-        @JsonProperty
-        private UUID uuid;
-
-        @JsonProperty
-        private String number;
-
-        JsonSignalServiceAddress(@JsonProperty("uuid") final UUID uuid, @JsonProperty("number") final String number) {
-            this.uuid = uuid;
-            this.number = number;
-        }
-
-        JsonSignalServiceAddress(SignalServiceAddress address) {
-            this.uuid = address.getUuid().orNull();
-            this.number = address.getNumber().orNull();
-        }
-
-        SignalServiceAddress toSignalServiceAddress() {
-            return new SignalServiceAddress(uuid, number);
-        }
-    }
-
-    private static class MembersSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
-
-        @Override
-        public void serialize(final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
-            jgen.writeStartArray(value.size());
-            for (SignalServiceAddress address : value) {
-                if (address.getUuid().isPresent()) {
-                    jgen.writeObject(new JsonSignalServiceAddress(address));
-                } else {
-                    jgen.writeString(address.getNumber().get());
-                }
-            }
-            jgen.writeEndArray();
-        }
-    }
-
-    private static class MembersDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
-
-        @Override
-        public Set<SignalServiceAddress> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
-            Set<SignalServiceAddress> addresses = new HashSet<>();
-            JsonNode node = jsonParser.getCodec().readTree(jsonParser);
-            for (JsonNode n : node) {
-                if (n.isTextual()) {
-                    addresses.add(new SignalServiceAddress(null, n.textValue()));
-                } else {
-                    JsonSignalServiceAddress address = jsonProcessor.treeToValue(n, JsonSignalServiceAddress.class);
-                    addresses.add(address.toSignalServiceAddress());
-                }
-            }
-
-            return addresses;
-        }
-    }
 }
diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java
new file mode 100644 (file)
index 0000000..9ec5178
--- /dev/null
@@ -0,0 +1,157 @@
+package org.asamk.signal.storage.groups;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+public class GroupInfoV1 extends GroupInfo {
+
+    private static final ObjectMapper jsonProcessor = new ObjectMapper();
+
+    @JsonProperty
+    public String name;
+
+    @JsonProperty
+    @JsonDeserialize(using = MembersDeserializer.class)
+    @JsonSerialize(using = MembersSerializer.class)
+    public Set<SignalServiceAddress> members = new HashSet<>();
+    @JsonProperty
+    public String color;
+    @JsonProperty(defaultValue = "0")
+    public int messageExpirationTime;
+    @JsonProperty(defaultValue = "false")
+    public boolean blocked;
+    @JsonProperty
+    public Integer inboxPosition;
+    @JsonProperty(defaultValue = "false")
+    public boolean archived;
+
+    public GroupInfoV1(byte[] groupId) {
+        super(groupId);
+    }
+
+    @Override
+    public String getTitle() {
+        return name;
+    }
+
+    public GroupInfoV1(@JsonProperty("groupId") byte[] groupId, @JsonProperty("name") String name, @JsonProperty("members") Collection<SignalServiceAddress> members, @JsonProperty("avatarId") long _ignored_avatarId, @JsonProperty("color") String color, @JsonProperty("blocked") boolean blocked, @JsonProperty("inboxPosition") Integer inboxPosition, @JsonProperty("archived") boolean archived, @JsonProperty("messageExpirationTime") int messageExpirationTime, @JsonProperty("active") boolean _ignored_active) {
+        super(groupId);
+        this.name = name;
+        this.members.addAll(members);
+        this.color = color;
+        this.blocked = blocked;
+        this.inboxPosition = inboxPosition;
+        this.archived = archived;
+        this.messageExpirationTime = messageExpirationTime;
+    }
+
+    @JsonIgnore
+    public Set<SignalServiceAddress> getMembers() {
+        return members;
+    }
+
+    @Override
+    public boolean isBlocked() {
+        return blocked;
+    }
+
+    @Override
+    public void setBlocked(final boolean blocked) {
+        this.blocked = blocked;
+    }
+
+    @Override
+    public int getMessageExpirationTime() {
+        return messageExpirationTime;
+    }
+
+    public void addMembers(Collection<SignalServiceAddress> addresses) {
+        for (SignalServiceAddress address : addresses) {
+            if (this.members.contains(address)) {
+                continue;
+            }
+            removeMember(address);
+            this.members.add(address);
+        }
+    }
+
+    public void removeMember(SignalServiceAddress address) {
+        this.members.removeIf(member -> member.matches(address));
+    }
+
+    private static final class JsonSignalServiceAddress {
+
+        @JsonProperty
+        private UUID uuid;
+
+        @JsonProperty
+        private String number;
+
+        JsonSignalServiceAddress(@JsonProperty("uuid") final UUID uuid, @JsonProperty("number") final String number) {
+            this.uuid = uuid;
+            this.number = number;
+        }
+
+        JsonSignalServiceAddress(SignalServiceAddress address) {
+            this.uuid = address.getUuid().orNull();
+            this.number = address.getNumber().orNull();
+        }
+
+        SignalServiceAddress toSignalServiceAddress() {
+            return new SignalServiceAddress(uuid, number);
+        }
+    }
+
+    private static class MembersSerializer extends JsonSerializer<Set<SignalServiceAddress>> {
+
+        @Override
+        public void serialize(final Set<SignalServiceAddress> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
+            jgen.writeStartArray(value.size());
+            for (SignalServiceAddress address : value) {
+                if (address.getUuid().isPresent()) {
+                    jgen.writeObject(new JsonSignalServiceAddress(address));
+                } else {
+                    jgen.writeString(address.getNumber().get());
+                }
+            }
+            jgen.writeEndArray();
+        }
+    }
+
+    private static class MembersDeserializer extends JsonDeserializer<Set<SignalServiceAddress>> {
+
+        @Override
+        public Set<SignalServiceAddress> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
+            Set<SignalServiceAddress> addresses = new HashSet<>();
+            JsonNode node = jsonParser.getCodec().readTree(jsonParser);
+            for (JsonNode n : node) {
+                if (n.isTextual()) {
+                    addresses.add(new SignalServiceAddress(null, n.textValue()));
+                } else {
+                    JsonSignalServiceAddress address = jsonProcessor.treeToValue(n, JsonSignalServiceAddress.class);
+                    addresses.add(address.toSignalServiceAddress());
+                }
+            }
+
+            return addresses;
+        }
+    }
+}
diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java
new file mode 100644 (file)
index 0000000..5e3115a
--- /dev/null
@@ -0,0 +1,70 @@
+package org.asamk.signal.storage.groups;
+
+import org.signal.storageservice.protos.groups.local.DecryptedGroup;
+import org.signal.zkgroup.groups.GroupMasterKey;
+import org.whispersystems.signalservice.api.push.SignalServiceAddress;
+import org.whispersystems.signalservice.api.util.UuidUtil;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class GroupInfoV2 extends GroupInfo {
+
+    private final GroupMasterKey masterKey;
+
+    private boolean blocked;
+    private DecryptedGroup group; // stored as a file with hexadecimal groupId as name
+
+    public GroupInfoV2(final byte[] groupId, final GroupMasterKey masterKey) {
+        super(groupId);
+        this.masterKey = masterKey;
+    }
+
+    public GroupMasterKey getMasterKey() {
+        return masterKey;
+    }
+
+    public void setGroup(final DecryptedGroup group) {
+        this.group = group;
+    }
+
+    public DecryptedGroup getGroup() {
+        return group;
+    }
+
+    @Override
+    public String getTitle() {
+        if (this.group == null) {
+            return null;
+        }
+        return this.group.getTitle();
+    }
+
+    @Override
+    public Set<SignalServiceAddress> getMembers() {
+        if (this.group == null) {
+            return Collections.emptySet();
+        }
+        return group.getMembersList().stream()
+                .map(m -> new SignalServiceAddress(UuidUtil.parseOrThrow(m.getUuid().toByteArray()), null))
+                .collect(Collectors.toSet());
+    }
+
+    @Override
+    public boolean isBlocked() {
+        return blocked;
+    }
+
+    @Override
+    public void setBlocked(final boolean blocked) {
+        this.blocked = blocked;
+    }
+
+    @Override
+    public int getMessageExpirationTime() {
+        return this.group != null && this.group.hasDisappearingMessagesTimer()
+                ? this.group.getDisappearingMessagesTimer().getDuration()
+                : 0;
+    }
+}
index b8186b8b4772f11272a3ee387c9267fd51df078b..c73858a1e46984fda73fcd6b5b11bfda789fb8c6 100644 (file)
@@ -12,10 +12,19 @@ import com.fasterxml.jackson.databind.SerializerProvider;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 
+import org.asamk.signal.util.Hex;
+import org.asamk.signal.util.IOUtils;
+import org.signal.storageservice.protos.groups.local.DecryptedGroup;
+import org.signal.zkgroup.InvalidInputException;
+import org.signal.zkgroup.groups.GroupMasterKey;
 import org.whispersystems.util.Base64;
 
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -23,31 +32,95 @@ import java.util.Map;
 public class JsonGroupStore {
 
     private static final ObjectMapper jsonProcessor = new ObjectMapper();
-
-    public static List<GroupInfo> groupsWithLegacyAvatarId = new ArrayList<>();
+    public File groupCachePath;
 
     @JsonProperty("groups")
-    @JsonSerialize(using = JsonGroupStore.MapToListSerializer.class)
-    @JsonDeserialize(using = JsonGroupStore.GroupsDeserializer.class)
-    private Map<String, GroupInfo> groups = new HashMap<>();
+    @JsonSerialize(using = GroupsSerializer.class)
+    @JsonDeserialize(using = GroupsDeserializer.class)
+    private final Map<String, GroupInfo> groups = new HashMap<>();
+
+    private JsonGroupStore() {
+    }
+
+    public JsonGroupStore(final File groupCachePath) {
+        this.groupCachePath = groupCachePath;
+    }
 
     public void updateGroup(GroupInfo group) {
         groups.put(Base64.encodeBytes(group.groupId), group);
+        if (group instanceof GroupInfoV2) {
+            try {
+                IOUtils.createPrivateDirectories(groupCachePath);
+                try (FileOutputStream stream = new FileOutputStream(getGroupFile(group.groupId))) {
+                    ((GroupInfoV2) group).getGroup().writeTo(stream);
+                }
+            } catch (IOException e) {
+                System.err.println("Failed to cache group, ignoring ...");
+            }
+        }
     }
 
     public GroupInfo getGroup(byte[] groupId) {
-        return groups.get(Base64.encodeBytes(groupId));
+        final GroupInfo group = groups.get(Base64.encodeBytes(groupId));
+        loadDecryptedGroup(group);
+        return group;
+    }
+
+    private void loadDecryptedGroup(final GroupInfo group) {
+        if (group instanceof GroupInfoV2 && ((GroupInfoV2) group).getGroup() == null) {
+            try (FileInputStream stream = new FileInputStream(getGroupFile(group.groupId))) {
+                ((GroupInfoV2) group).setGroup(DecryptedGroup.parseFrom(stream));
+            } catch (IOException ignored) {
+            }
+        }
+    }
+
+    private File getGroupFile(final byte[] groupId) {
+        return new File(groupCachePath, Hex.toStringCondensed(groupId));
+    }
+
+    public GroupInfoV1 getOrCreateGroupV1(byte[] groupId) {
+        GroupInfo group = groups.get(Base64.encodeBytes(groupId));
+        if (group instanceof GroupInfoV1) {
+            return (GroupInfoV1) group;
+        }
+
+        if (group == null) {
+            return new GroupInfoV1(groupId);
+        }
+
+        return null;
     }
 
     public List<GroupInfo> getGroups() {
-        return new ArrayList<>(groups.values());
+        final Collection<GroupInfo> groups = this.groups.values();
+        for (GroupInfo group : groups) {
+            loadDecryptedGroup(group);
+        }
+        return new ArrayList<>(groups);
     }
 
-    private static class MapToListSerializer extends JsonSerializer<Map<?, ?>> {
+    private static class GroupsSerializer extends JsonSerializer<Map<String, GroupInfo>> {
 
         @Override
-        public void serialize(final Map<?, ?> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
-            jgen.writeObject(value.values());
+        public void serialize(final Map<String, GroupInfo> value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException {
+            final Collection<GroupInfo> groups = value.values();
+            jgen.writeStartArray(groups.size());
+            for (GroupInfo group : groups) {
+                if (group instanceof GroupInfoV1) {
+                    jgen.writeObject(group);
+                } else if (group instanceof GroupInfoV2) {
+                    final GroupInfoV2 groupV2 = (GroupInfoV2) group;
+                    jgen.writeStartObject();
+                    jgen.writeStringField("groupId", Base64.encodeBytes(groupV2.groupId));
+                    jgen.writeStringField("masterKey", Base64.encodeBytes(groupV2.getMasterKey().serialize()));
+                    jgen.writeBooleanField("blocked", groupV2.isBlocked());
+                    jgen.writeEndObject();
+                } else {
+                    throw new AssertionError("Unknown group version");
+                }
+            }
+            jgen.writeEndArray();
         }
     }
 
@@ -58,10 +131,19 @@ public class JsonGroupStore {
             Map<String, GroupInfo> groups = new HashMap<>();
             JsonNode node = jsonParser.getCodec().readTree(jsonParser);
             for (JsonNode n : node) {
-                GroupInfo g = jsonProcessor.treeToValue(n, GroupInfo.class);
-                // Check if a legacy avatarId exists
-                if (g.getAvatarId() != 0) {
-                    groupsWithLegacyAvatarId.add(g);
+                GroupInfo g;
+                if (n.has("masterKey")) {
+                    // a v2 group
+                    byte[] groupId = Base64.decode(n.get("groupId").asText());
+                    try {
+                        GroupMasterKey masterKey = new GroupMasterKey(Base64.decode(n.get("masterKey").asText()));
+                        g = new GroupInfoV2(groupId, masterKey);
+                    } catch (InvalidInputException e) {
+                        throw new AssertionError("Invalid master key for group " + Base64.encodeBytes(groupId));
+                    }
+                    g.setBlocked(n.get("blocked").asBoolean(false));
+                } else {
+                    g = jsonProcessor.treeToValue(n, GroupInfoV1.class);
                 }
                 groups.put(Base64.encodeBytes(g.groupId), g);
             }
index 1163d07949b661a393039c7d10e540d1e27375c4..4d8adea6bbaa166053e7ac30ed638008e2273fff 100644 (file)
@@ -48,6 +48,10 @@ public class IOUtils {
 
     public static void createPrivateDirectories(String directoryPath) throws IOException {
         final File file = new File(directoryPath);
+        createPrivateDirectories(file);
+    }
+
+    public static void createPrivateDirectories(File file) throws IOException {
         if (file.exists()) {
             return;
         }