From: AsamK Date: Sun, 22 Nov 2020 18:47:10 +0000 (+0100) Subject: Implement support for sending/receiving Group V2 messages X-Git-Tag: v0.7.0~12 X-Git-Url: https://git.nmode.ca/signal-cli/commitdiff_plain/6a1b7dc5975161f59fbb1012dd340c7b6e0198f7?ds=sidebyside Implement support for sending/receiving Group V2 messages Requires libzkgroup to work, which is currently only included for x86_64 Linux Related #354 --- diff --git a/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java b/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java index 5973d019..4e4c33cf 100644 --- a/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/JsonDbusReceiveMessageHandler.java @@ -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 getAttachments(SignalServiceDataMessage message, Manager m) { List attachments = new ArrayList<>(); if (message.getAttachments().isPresent()) { diff --git a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java index 0a264432..f32303b1 100644 --- a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java @@ -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: "); + System.out.println(" Name: "); } } } @@ -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: "); } @@ -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: "); + } 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()); } diff --git a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java index 0baa8744..9e13685e 100644 --- a/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java +++ b/src/main/java/org/asamk/signal/commands/ListGroupsCommand.java @@ -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 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; } diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index bffde498..77b3bc99 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -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()); } } diff --git a/src/main/java/org/asamk/signal/manager/Manager.java b/src/main/java/org/asamk/signal/manager/Manager.java index 83a3926a..07c8b583 100644 --- a/src/main/java/org/asamk/signal/manager/Manager.java +++ b/src/main/java/org/asamk/signal/manager/Manager.java @@ -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> 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> sendUpdateGroupMessage(byte[] groupId, String name, Collection 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> 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> sendMessage(SignalServiceDataMessage.Builder messageBuilder, Collection 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 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 handleSignalServiceDataMessage(SignalServiceDataMessage message, boolean isSync, SignalServiceAddress source, SignalServiceAddress destination, boolean ignoreAttachments) { List 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 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 getIdentities() { return account.getSignalProtocolStore().getIdentities(); } diff --git a/src/main/java/org/asamk/signal/manager/ServiceConfig.java b/src/main/java/org/asamk/signal/manager/ServiceConfig.java index 4ea41734..4498fc65 100644 --- a/src/main/java/org/asamk/signal/manager/ServiceConfig.java +++ b/src/main/java/org/asamk/signal/manager/ServiceConfig.java @@ -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 = 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 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 makeSignalCdnUrlMapFor(SignalCdnUrl[] cdn0Urls, SignalCdnUrl[] cdn2Urls) { - Map result = new HashMap<>(); - result.put(0, cdn0Urls); - result.put(2, cdn2Urls); - return Collections.unmodifiableMap(result); + return Map.of(0, cdn0Urls, 2, cdn2Urls); } private ServiceConfig() { diff --git a/src/main/java/org/asamk/signal/storage/SignalAccount.java b/src/main/java/org/asamk/signal/storage/SignalAccount.java index 6043d803..d3c2506d 100644 --- a/src/main/java/org/asamk/signal/storage/SignalAccount.java +++ b/src/main/java/org/asamk/signal/storage/SignalAccount.java @@ -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 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); } } diff --git a/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java b/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java index 4b0adcd0..db4f4690 100644 --- a/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfo.java @@ -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 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 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 getMembers(); @JsonIgnore - public Set getMembers() { - return members; - } + public abstract boolean isBlocked(); @JsonIgnore - public Set getMembersE164() { - Set 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 getMembersWithout(SignalServiceAddress address) { - Set members = new HashSet<>(this.members.size()); - for (SignalServiceAddress member : this.members) { + Set 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 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> { - - @Override - public void serialize(final Set 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> { - - @Override - public Set deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - Set 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 index 00000000..9ec5178b --- /dev/null +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV1.java @@ -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 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 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 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 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> { + + @Override + public void serialize(final Set 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> { + + @Override + public Set deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + Set 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 index 00000000..5e3115a1 --- /dev/null +++ b/src/main/java/org/asamk/signal/storage/groups/GroupInfoV2.java @@ -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 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; + } +} diff --git a/src/main/java/org/asamk/signal/storage/groups/JsonGroupStore.java b/src/main/java/org/asamk/signal/storage/groups/JsonGroupStore.java index b8186b8b..c73858a1 100644 --- a/src/main/java/org/asamk/signal/storage/groups/JsonGroupStore.java +++ b/src/main/java/org/asamk/signal/storage/groups/JsonGroupStore.java @@ -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 groupsWithLegacyAvatarId = new ArrayList<>(); + public File groupCachePath; @JsonProperty("groups") - @JsonSerialize(using = JsonGroupStore.MapToListSerializer.class) - @JsonDeserialize(using = JsonGroupStore.GroupsDeserializer.class) - private Map groups = new HashMap<>(); + @JsonSerialize(using = GroupsSerializer.class) + @JsonDeserialize(using = GroupsDeserializer.class) + private final Map 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 getGroups() { - return new ArrayList<>(groups.values()); + final Collection groups = this.groups.values(); + for (GroupInfo group : groups) { + loadDecryptedGroup(group); + } + return new ArrayList<>(groups); } - private static class MapToListSerializer extends JsonSerializer> { + private static class GroupsSerializer extends JsonSerializer> { @Override - public void serialize(final Map value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException { - jgen.writeObject(value.values()); + public void serialize(final Map value, final JsonGenerator jgen, final SerializerProvider provider) throws IOException { + final Collection 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 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); } diff --git a/src/main/java/org/asamk/signal/util/IOUtils.java b/src/main/java/org/asamk/signal/util/IOUtils.java index 1163d079..4d8adea6 100644 --- a/src/main/java/org/asamk/signal/util/IOUtils.java +++ b/src/main/java/org/asamk/signal/util/IOUtils.java @@ -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; }