Implement group send endorsements

This commit is contained in:
AsamK 2025-09-12 23:38:39 +02:00
parent b2076cfc12
commit f469a4d166
4 changed files with 307 additions and 99 deletions

View File

@ -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<GroupId, SendGroupMessageResults> 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<RecipientId, ProfileKey>();
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);
}

View File

@ -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<RecipientId> senderKeyTargets = groupInfo.getDistributionId() == null
Set<RecipientId> 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<SendMessageResult>(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<Long, Map<RecipientId, GroupSendEndorsement>> 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<RecipientId, GroupSendEndorsement> 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;
}

View File

@ -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(

View File

@ -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<RecipientId, GroupSendEndorsement> 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<RecipientId, GroupSendEndorsement> 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<RecipientId, GroupSendEndorsement> 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)) {