diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java index c54a5ae2..5917787d 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/GroupHelper.java @@ -31,8 +31,8 @@ import org.asamk.signal.manager.util.Utils; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupSecretParams; +import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse; import org.signal.libsignal.zkgroup.profiles.ProfileKey; -import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.protos.groups.GroupChangeResponse; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; @@ -42,7 +42,6 @@ import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupResponse; import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException; -import org.whispersystems.signalservice.api.groupsv2.ReceivedGroupSendEndorsements; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; @@ -59,9 +58,11 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; public class GroupHelper { @@ -145,8 +146,9 @@ public class GroupHelper { final var response = context.getGroupV2Helper().getDecryptedGroup(groupSecretParams); if (response != null) { - group = handleDecryptedGroupResponse(groupInfoV2, response); + group = response.getGroup(); storeProfileKeysFromHistory(groupSecretParams, groupInfoV2, group); + handleDecryptedGroupResponse(groupInfoV2, response); } } catch (NotAGroupMemberException ignored) { } @@ -166,35 +168,53 @@ public class GroupHelper { return groupInfoV2; } - private DecryptedGroup handleDecryptedGroupResponse( + private void handleDecryptedGroupResponse( GroupInfoV2 groupInfoV2, final DecryptedGroupResponse decryptedGroupResponse ) { - final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey()); - ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations() - .forGroup(groupSecretParams) - .receiveGroupSendEndorsements(account.getAci(), - decryptedGroupResponse.getGroup(), - decryptedGroupResponse.getGroupSendEndorsementsResponse()); - - // TODO save group endorsements - - return decryptedGroupResponse.getGroup(); + updateGroupEndorsements(groupInfoV2.getGroupId(), + groupInfoV2.getMasterKey(), + decryptedGroupResponse.getGroup(), + decryptedGroupResponse.getGroupSendEndorsementsResponse()); } - private GroupChange handleGroupChangeResponse( + private void handleGroupChangeResponse( final GroupInfoV2 groupInfoV2, final GroupChangeResponse groupChangeResponse ) { - ReceivedGroupSendEndorsements groupSendEndorsements = dependencies.getGroupsV2Operations() - .forGroup(GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey())) - .receiveGroupSendEndorsements(account.getAci(), + if (groupChangeResponse.groupSendEndorsementsResponse.size() > 0) { + try { + final var groupSendEndorsementsResponse = new GroupSendEndorsementsResponse(groupChangeResponse.groupSendEndorsementsResponse.toByteArray()); + + updateGroupEndorsements(groupInfoV2.getGroupId(), + groupInfoV2.getMasterKey(), groupInfoV2.getGroup(), - groupChangeResponse.groupSendEndorsementsResponse); + groupSendEndorsementsResponse); + } catch (InvalidInputException e) { + logger.debug("Unable to parse send endorsements response", e); + } + } + } - // TODO save group endorsements + private void updateGroupEndorsements( + final GroupIdV2 groupId, + final GroupMasterKey masterKey, + final DecryptedGroup group, + final GroupSendEndorsementsResponse groupSendEndorsementsResponse + ) { + final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(masterKey); + final var groupEndorsements = dependencies.getGroupsV2Operations() + .forGroup(groupSecretParams) + .receiveGroupSendEndorsements(account.getAci(), group, groupSendEndorsementsResponse); - return groupChangeResponse.groupChange; + if (groupEndorsements != null) { + final var endorsements = groupEndorsements.getEndorsements() + .entrySet() + .stream() + .collect(Collectors.toMap(entry -> account.getRecipientResolver().resolveRecipient(entry.getKey()), + Map.Entry::getValue)); + account.getGroupStore().updateGroupEndorsements(groupId, groupEndorsements.getExpirationMs(), endorsements); + } } public Pair createGroup( @@ -224,7 +244,9 @@ public class GroupHelper { final var gv2 = gv2Pair.first(); final var decryptedGroup = gv2Pair.second(); - gv2.setGroup(handleDecryptedGroupResponse(gv2, decryptedGroup)); + gv2.setGroup(decryptedGroup.getGroup()); + handleDecryptedGroupResponse(gv2, decryptedGroup); + gv2.setProfileSharingEnabled(true); if (avatarBytes != null) { context.getAvatarStore() @@ -329,9 +351,7 @@ public class GroupHelper { groupChangePair = context.getGroupV2Helper().updateSelfProfileKey(groupInfoV2); } if (groupChangePair != null) { - sendUpdateGroupV2Message(groupInfoV2, - groupChangePair.first(), - handleGroupChangeResponse(groupInfoV2, groupChangePair.second())); + sendUpdateGroupV2Message(groupInfoV2, groupChangePair.first(), groupChangePair.second()); } } } @@ -354,14 +374,13 @@ public class GroupHelper { final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(), groupJoinInfo.revision + 1, changeResponse.groupChange == null ? null : changeResponse.groupChange.encode()); - final var groupChange = handleGroupChangeResponse(group, changeResponse); if (group.getGroup() == null) { // Only requested member, can't send update to group members return new Pair<>(group.getGroupId(), new SendGroupMessageResults(0, List.of())); } - final var result = sendUpdateGroupV2Message(group, group.getGroup(), groupChange); + final var result = sendUpdateGroupV2Message(group, group.getGroup(), changeResponse); context.getJobExecutor().enqueueJob(new SyncStorageJob()); return new Pair<>(group.getGroupId(), result); @@ -454,7 +473,8 @@ public class GroupHelper { if (response == null) { return; } - decryptedGroup = handleDecryptedGroupResponse(groupInfoV2, response); + decryptedGroup = response.getGroup(); + handleDecryptedGroupResponse(groupInfoV2, response); } catch (NotAGroupMemberException e) { groupInfoV2.setPermissionDenied(true); account.getGroupStore().updateGroup(group); @@ -544,7 +564,8 @@ public class GroupHelper { ) throws NotAGroupMemberException { final var revisionWeWereAdded = context.getGroupV2Helper().findRevisionWeWereAdded(newDecryptedGroup); final var localRevision = localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision; - final var sendEndorsementsExpirationMs = 0L;// TODO store expiration localGroup.getGroup() == null ? 0 : localGroup.getGroup().revision; + final var sendEndorsementsExpirationMs = account.getGroupStore() + .getGroupEndorsementExpirationMs(localGroup.getGroupId()); var fromRevision = Math.max(revisionWeWereAdded, localRevision); final var newProfileKeys = new HashMap(); while (true) { @@ -666,9 +687,7 @@ public class GroupHelper { final var groupV2Helper = context.getGroupV2Helper(); if (group.isPendingMember(account.getSelfRecipientId())) { var groupGroupChangePair = groupV2Helper.acceptInvite(group); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (members != null) { @@ -676,18 +695,14 @@ public class GroupHelper { requestingMembers.retainAll(group.getRequestingMembers()); if (!requestingMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.approveJoinRequestMembers(group, requestingMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } final var newMembers = new HashSet<>(members); newMembers.removeAll(group.getMembers()); newMembers.removeAll(group.getRequestingMembers()); if (!newMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } } @@ -703,26 +718,20 @@ public class GroupHelper { existingRemoveMembers.remove(account.getSelfRecipientId());// self can be removed with sendQuitGroupMessage if (!existingRemoveMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.removeMembers(group, existingRemoveMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } var pendingRemoveMembers = new HashSet<>(removeMembers); pendingRemoveMembers.retainAll(group.getPendingMembers()); if (!pendingRemoveMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.revokeInvitedMembers(group, pendingRemoveMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } var requestingRemoveMembers = new HashSet<>(removeMembers); requestingRemoveMembers.retainAll(group.getRequestingMembers()); if (!requestingRemoveMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.refuseJoinRequestMembers(group, requestingRemoveMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } } @@ -735,7 +744,7 @@ public class GroupHelper { var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + groupGroupChangePair.second()); } } } @@ -748,7 +757,7 @@ public class GroupHelper { var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + groupGroupChangePair.second()); } } } @@ -758,9 +767,7 @@ public class GroupHelper { newlyBannedMembers.removeAll(group.getBannedMembers()); if (!newlyBannedMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.banMembers(group, newlyBannedMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } } @@ -769,52 +776,38 @@ public class GroupHelper { existingUnbanMembers.retainAll(group.getBannedMembers()); if (!existingUnbanMembers.isEmpty()) { var groupGroupChangePair = groupV2Helper.unbanMembers(group, existingUnbanMembers); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } } if (resetGroupLink) { var groupGroupChangePair = groupV2Helper.resetGroupLinkPassword(group); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (groupLinkState != null) { var groupGroupChangePair = groupV2Helper.setGroupLinkState(group, groupLinkState); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (addMemberPermission != null) { var groupGroupChangePair = groupV2Helper.setAddMemberPermission(group, addMemberPermission); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (editDetailsPermission != null) { var groupGroupChangePair = groupV2Helper.setEditDetailsPermission(group, editDetailsPermission); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (expirationTimer != null) { var groupGroupChangePair = groupV2Helper.setMessageExpirationTimer(group, expirationTimer); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (isAnnouncementGroup != null) { var groupGroupChangePair = groupV2Helper.setIsAnnouncementGroup(group, isAnnouncementGroup); - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } if (name != null || description != null || avatarFile != null) { @@ -823,9 +816,7 @@ public class GroupHelper { context.getAvatarStore() .storeGroupAvatar(group.getGroupId(), outputStream -> outputStream.write(avatarFile)); } - result = sendUpdateGroupV2Message(group, - groupGroupChangePair.first(), - handleGroupChangeResponse(group, groupGroupChangePair.second())); + result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); } return result; @@ -862,8 +853,12 @@ public class GroupHelper { groupInfoV2.setGroup(groupGroupChangePair.first()); account.getGroupStore().updateGroup(groupInfoV2); - var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, - handleGroupChangeResponse(groupInfoV2, groupGroupChangePair.second()).encode()); + final var groupChangeResponse = groupGroupChangePair.second(); + handleGroupChangeResponse(groupInfoV2, groupChangeResponse); + if (groupChangeResponse.groupChange == null) { + throw new AssertionError("groupChange is null"); + } + var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupChangeResponse.groupChange.encode()); return sendGroupMessage(messageBuilder, groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()), groupInfoV2); @@ -902,15 +897,19 @@ public class GroupHelper { private SendGroupMessageResults sendUpdateGroupV2Message( GroupInfoV2 group, DecryptedGroup newDecryptedGroup, - GroupChange groupChange + GroupChangeResponse groupChangeResponse ) throws IOException { final var selfRecipientId = account.getSelfRecipientId(); final var members = group.getMembersIncludingPendingWithout(selfRecipientId); group.setGroup(newDecryptedGroup); + handleGroupChangeResponse(group, groupChangeResponse); members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId)); account.getGroupStore().updateGroup(group); - final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChange.encode()); + if (groupChangeResponse.groupChange == null) { + throw new AssertionError("groupChange is null"); + } + final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChangeResponse.groupChange.encode()); return sendGroupMessage(messageBuilder, members, group); } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index deeceff6..d9ac0a55 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -5,20 +5,23 @@ import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.api.GroupNotFoundException; import org.asamk.signal.manager.api.GroupSendingNotAllowedException; import org.asamk.signal.manager.api.NotAGroupMemberException; +import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.api.Profile; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.groups.GroupInfo; +import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.sendLog.MessageSendLogEntry; -import org.jetbrains.annotations.Nullable; import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidRegistrationIdException; import org.signal.libsignal.protocol.NoSessionException; import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.protocol.message.DecryptionErrorMessage; +import org.signal.libsignal.zkgroup.groups.GroupSecretParams; +import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsement; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.signalservice.api.SignalServiceMessageSender; @@ -35,6 +38,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.DistributionId; +import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; @@ -45,6 +49,7 @@ import org.whispersystems.signalservice.internal.push.http.PartialSendCompleteLi import java.io.IOException; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -479,12 +484,18 @@ public class SendHelper { final var addressesMap = recipientIds.stream() .collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress)); final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds); - final GroupSendEndorsements groupSendEndorsements = null; //TODO + final var groupSendEndorsements = getGroupSendEndorsements(groupInfo); + final var groupSecretParams = groupInfo instanceof GroupInfoV2 gv2 + ? GroupSecretParams.deriveFromMasterKey((gv2.getMasterKey())) + : null; - Set senderKeyTargets = groupInfo.getDistributionId() == null + Set senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null ? Set.of() : recipientIds.stream() - .filter(s -> this.isSenderKeyCapable(addressesMap.get(s), unidentifiedAccessesMap.get(s))) + .filter(s -> this.isSenderKeyCapable(s, + addressesMap.get(s), + unidentifiedAccessesMap.get(s), + groupSendEndorsements.second())) .collect(Collectors.toSet()); if (senderKeyTargets.size() < 2) { logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size()); @@ -495,11 +506,23 @@ public class SendHelper { final var allResults = new ArrayList(recipientIds.size()); if (!senderKeyTargets.isEmpty()) { + final var senderCertificate = this.context.getUnidentifiedAccessHelper().getSenderCertificateFor(null); + final var addresses = senderKeyTargets.stream().map(addressesMap::get).toList(); + final var targets = senderKeyTargets; + final var requiredGroupSendEndorsements = new GroupSendEndorsements(groupSendEndorsements.first(), + groupSendEndorsements.second() + .entrySet() + .stream() + .filter(entry -> targets.contains(entry.getKey())) + .collect(Collectors.toMap(entry -> (ACI) addressesMap.get(entry.getKey()).getServiceId(), + Map.Entry::getValue)), + senderCertificate, + groupSecretParams); final var results = sendGroupMessageInternalWithSenderKey(senderKeySender, groupInfo.getDistributionId(), - senderKeyTargets.stream().map(addressesMap::get).toList(), + addresses, senderKeyTargets.stream().map(unidentifiedAccessesMap::get).toList(), - groupSendEndorsements, + requiredGroupSendEndorsements, isRecipientUpdate); if (results == null) { @@ -536,8 +559,16 @@ public class SendHelper { .findFirst() .map(UnidentifiedAccess::getUnidentifiedCertificate) .orElse(null); - final var groupSendTokens = groupSendEndorsements != null - ? groupSendEndorsements.forIndividuals(addresses) + final var groupSendTokens = groupSendEndorsements != null ? groupSendEndorsements.second() + .entrySet() + .stream() + .filter(entry -> legacyTargets.contains(entry.getKey())) + .map(Map.Entry::getValue) + .map(e -> e == null || groupSecretParams == null + ? null + : e.toFullToken(groupSecretParams, Instant.ofEpochMilli(groupSendEndorsements.first()))) + .toList() + : null; final var sealedSenderAccesses = SealedSenderAccess.forFanOutGroupSend(groupSendTokens, senderCertificate, @@ -553,16 +584,45 @@ public class SendHelper { return allResults; } - private boolean isSenderKeyCapable(final SignalServiceAddress address, final UnidentifiedAccess access) { - if (access == null) { + private Pair> getGroupSendEndorsements(final GroupInfo groupInfo) { + if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) { + return null; + } + + var groupSendEndorsementMap = account.getGroupStore().getGroupEndorsements(groupInfoV2.getGroupId()); + var groupSendEndorsementExpirationMs = account.getGroupStore() + .getGroupEndorsementExpirationMs(groupInfoV2.getGroupId()); + if (groupSendEndorsementMap.isEmpty() + || groupSendEndorsementExpirationMs - TimeUnit.HOURS.toMillis(2) < System.currentTimeMillis()) { + logger.debug("No group send endorsements available, trying to update"); + this.context.getGroupHelper().updateGroupSendEndorsements(groupInfoV2.getGroupId()); + groupSendEndorsementMap = account.getGroupStore().getGroupEndorsements(groupInfoV2.getGroupId()); + groupSendEndorsementExpirationMs = account.getGroupStore() + .getGroupEndorsementExpirationMs(groupInfoV2.getGroupId()); + if (groupSendEndorsementMap.isEmpty() + || groupSendEndorsementExpirationMs - TimeUnit.HOURS.toMillis(2) < System.currentTimeMillis()) { + logger.debug("Updating group send endorsements was not successful"); + return null; + } + } + return new Pair(groupSendEndorsementExpirationMs, groupSendEndorsementMap); + } + + private boolean isSenderKeyCapable( + final RecipientId recipientId, + final SignalServiceAddress address, + final UnidentifiedAccess access, + final Map groupSendEndorsements + ) { + if (access == null || !address.hasValidServiceId() || !(address.getServiceId() instanceof ACI aci)) { return false; } - if (!address.hasValidServiceId()) { + if (!groupSendEndorsements.containsKey(recipientId)) { return false; } - final var identity = account.getIdentityKeyStore().getIdentityInfo(address.getServiceId()); + final var identity = account.getIdentityKeyStore().getIdentityInfo(aci); if (identity == null || !identity.getTrustLevel().isTrusted()) { return false; } @@ -771,7 +831,7 @@ public class SendHelper { SendMessageResult send( SignalServiceMessageSender messageSender, SignalServiceAddress address, - @Nullable SealedSenderAccess unidentifiedAccess, + SealedSenderAccess unidentifiedAccess, boolean includePniSignature ) throws IOException, UnregisteredUserException, ProofRequiredException, RateLimitException, org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; } diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java index 9e1c6303..b1134814 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/AccountDatabase.java @@ -33,7 +33,7 @@ import java.util.UUID; public class AccountDatabase extends Database { private static final Logger logger = LoggerFactory.getLogger(AccountDatabase.class); - private static final long DATABASE_VERSION = 27; + private static final long DATABASE_VERSION = 28; private AccountDatabase(final HikariDataSource dataSource) { super(logger, DATABASE_VERSION, dataSource); @@ -608,6 +608,21 @@ public class AccountDatabase extends Database { """); } } + if (oldVersion < 28) { + logger.debug("Updating database: Adding group endorsements"); + try (final var statement = connection.createStatement()) { + statement.executeUpdate(""" + ALTER TABLE group_v2 ADD endorsement_expiration_time INTEGER DEFAULT 0 NOT NULL; + CREATE TABLE group_v2_member ( + _id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES group_v2 (_id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + endorsement BLOB NOT NULL, + UNIQUE(group_id, recipient_id) + ) STRICT; + """); + } + } } private static void createUuidMappingTable( diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java index 26c4c08d..cb9bd24b 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/groups/GroupStore.java @@ -3,6 +3,7 @@ package org.asamk.signal.manager.storage.groups; import org.asamk.signal.manager.api.GroupId; import org.asamk.signal.manager.api.GroupIdV1; import org.asamk.signal.manager.api.GroupIdV2; +import org.asamk.signal.manager.api.Pair; import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.manager.storage.Database; import org.asamk.signal.manager.storage.Utils; @@ -13,6 +14,7 @@ import org.asamk.signal.manager.util.KeyUtils; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupSecretParams; +import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsement; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +41,7 @@ public class GroupStore { private static final Logger logger = LoggerFactory.getLogger(GroupStore.class); private static final String TABLE_GROUP_V2 = "group_v2"; + private static final String TABLE_GROUP_V2_MEMBER = "group_v2_member"; private static final String TABLE_GROUP_V1 = "group_v1"; private static final String TABLE_GROUP_V1_MEMBER = "group_v1_member"; @@ -58,10 +61,18 @@ public class GroupStore { master_key BLOB NOT NULL, group_data BLOB, distribution_id BLOB UNIQUE NOT NULL, + endorsement_expiration_time INTEGER NOT NULL DEFAULT 0, blocked INTEGER NOT NULL DEFAULT FALSE, profile_sharing INTEGER NOT NULL DEFAULT FALSE, permission_denied INTEGER NOT NULL DEFAULT FALSE ) STRICT; + CREATE TABLE group_v2_member ( + _id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES group_v2 (_id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + endorsement BLOB NOT NULL, + UNIQUE(group_id, recipient_id) + ) STRICT; CREATE TABLE group_v1 ( _id INTEGER PRIMARY KEY, storage_id BLOB UNIQUE, @@ -100,7 +111,7 @@ public class GroupStore { updateGroup(connection, group); connection.commit(); } catch (SQLException e) { - throw new RuntimeException("Failed update recipient store", e); + throw new RuntimeException("Failed update group store", e); } } @@ -120,6 +131,113 @@ public class GroupStore { insertOrReplaceGroup(connection, internalId, group); } + public void updateGroupEndorsements( + final GroupIdV2 groupId, + final long expirationMs, + Map endorsements + ) { + try (final var connection = database.getConnection()) { + connection.setAutoCommit(false); + final var sql = ( + """ + UPDATE %s + SET endorsement_expiration_time = ? + WHERE group_id = ? + RETURNING _id + """ + ).formatted(TABLE_GROUP_V2); + long internalId; + try (final var statement = connection.prepareStatement(sql)) { + statement.setLong(1, endorsements == null ? 0 : expirationMs); + statement.setBytes(2, groupId.serialize()); + final var result = Utils.executeQueryForOptional(statement, Utils::getIdMapper); + if (result.isEmpty()) { + return; + } + internalId = result.get(); + } + + final var sqlDeleteMembers = "DELETE FROM %s where group_id = ?".formatted(TABLE_GROUP_V2_MEMBER); + try (final var statement = connection.prepareStatement(sqlDeleteMembers)) { + statement.setLong(1, internalId); + statement.executeUpdate(); + } + + if (endorsements != null) { + final var sqlInsertMember = """ + INSERT OR REPLACE INTO %s (group_id, recipient_id, endorsement) + VALUES (?, ?, ?) + """.formatted(TABLE_GROUP_V2_MEMBER); + try (final var statement = connection.prepareStatement(sqlInsertMember)) { + for (final var entry : endorsements.entrySet()) { + final var recipientId = entry.getKey(); + final var endorsement = entry.getValue(); + statement.setLong(1, internalId); + statement.setLong(2, recipientId.id()); + statement.setBytes(3, endorsement.serialize()); + statement.executeUpdate(); + } + } + } + connection.commit(); + } catch (SQLException e) { + throw new RuntimeException("Failed update group store", e); + } + } + + public long getGroupEndorsementExpirationMs(final GroupIdV2 groupId) { + final var sql = ( + """ + SELECT g.endorsement_expiration_time + FROM %s g + WHERE g.group_id = ? + """ + ).formatted(TABLE_GROUP_V2); + try (final var connection = database.getConnection()) { + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, groupId.serialize()); + return Utils.executeQueryForOptional(statement, this::getGroupEndorsementMsFromResultSet).orElse(0L); + } + } catch (SQLException e) { + throw new RuntimeException("Failed read from group store", e); + } + } + + public Map getGroupEndorsements(final GroupIdV2 groupId) { + final var sql = ( + """ + SELECT gm.recipient_id, gm.endorsement + FROM %s gm, %s g + WHERE gm.group_id = g._id AND g.group_id = ? + """ + ).formatted(TABLE_GROUP_V2_MEMBER, TABLE_GROUP_V2); + try (final var connection = database.getConnection()) { + try (final var statement = connection.prepareStatement(sql)) { + statement.setBytes(1, groupId.serialize()); + return Utils.executeQueryForStream(statement, this::getGroupEndorsementsFromResultSet) + .filter(Objects::nonNull) + .collect(Collectors.toMap(Pair::first, Pair::second)); + } + } catch (SQLException e) { + throw new RuntimeException("Failed read from group store", e); + } + } + + private Pair getGroupEndorsementsFromResultSet(ResultSet resultSet) throws SQLException { + final var endorsement = resultSet.getBytes("endorsement"); + final var recipientId = resultSet.getLong("recipient_id"); + + try { + return new Pair<>(recipientIdCreator.create(recipientId), new GroupSendEndorsement(endorsement)); + } catch (InvalidInputException e) { + throw new AssertionError(e); + } + } + + private long getGroupEndorsementMsFromResultSet(ResultSet resultSet) throws SQLException { + return resultSet.getLong("endorsement_expiration_time"); + } + public void storeStorageRecord( final Connection connection, final GroupId groupId, @@ -333,19 +451,34 @@ public class GroupStore { final RecipientId recipientId, final RecipientId toBeMergedRecipientId ) throws SQLException { - final var sql = ( + final var sqlV1 = ( """ UPDATE OR REPLACE %s SET recipient_id = ? WHERE recipient_id = ? """ ).formatted(TABLE_GROUP_V1_MEMBER); - try (final var statement = connection.prepareStatement(sql)) { + try (final var statement = connection.prepareStatement(sqlV1)) { statement.setLong(1, recipientId.id()); statement.setLong(2, toBeMergedRecipientId.id()); final var updatedRows = statement.executeUpdate(); if (updatedRows > 0) { - logger.debug("Updated {} group members when merging recipients", updatedRows); + logger.debug("Updated {} group v1 members when merging recipients", updatedRows); + } + } + final var sqlV2 = ( + """ + UPDATE OR REPLACE %s + SET recipient_id = ? + WHERE recipient_id = ? + """ + ).formatted(TABLE_GROUP_V2_MEMBER); + try (final var statement = connection.prepareStatement(sqlV2)) { + statement.setLong(1, recipientId.id()); + statement.setLong(2, toBeMergedRecipientId.id()); + final var updatedRows = statement.executeUpdate(); + if (updatedRows > 0) { + logger.debug("Updated {} group v2 members when merging recipients", updatedRows); } } } @@ -521,8 +654,9 @@ public class GroupStore { } else if (group instanceof GroupInfoV2 groupV2) { final var sql = ( """ - INSERT OR REPLACE INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, permission_denied, storage_id, profile_sharing) + INSERT INTO %s (_id, group_id, master_key, group_data, distribution_id, blocked, permission_denied, storage_id, profile_sharing) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (_id) DO UPDATE SET group_id=excluded.group_id, master_key=excluded.master_key, group_data=excluded.group_data, distribution_id=excluded.distribution_id, blocked=excluded.blocked, permission_denied=excluded.permission_denied, storage_id=excluded.storage_id, profile_sharing=excluded.profile_sharing """ ).formatted(TABLE_GROUP_V2); try (final var statement = connection.prepareStatement(sql)) {