mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 11:28:05 +01:00
Group Send Credential support in chat
This commit is contained in:
committed by
GitHub
parent
195f23c347
commit
e1ad25cee0
@@ -812,7 +812,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, receiptSender,
|
||||
accountsManager, messagesManager, pushNotificationManager, reportMessageManager,
|
||||
multiRecipientMessageExecutor, messageDeliveryScheduler, reportSpamTokenProvider, clientReleaseManager,
|
||||
dynamicConfigurationManager),
|
||||
dynamicConfigurationManager, zkSecretParams),
|
||||
new PaymentsController(currencyManager, paymentsCredentialsGenerator),
|
||||
new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,
|
||||
profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.auth;
|
||||
|
||||
import java.util.Base64;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groupsend.GroupSendCredentialPresentation;
|
||||
|
||||
public record GroupSendCredentialHeader(GroupSendCredentialPresentation presentation) {
|
||||
|
||||
public static GroupSendCredentialHeader valueOf(String header) {
|
||||
try {
|
||||
return new GroupSendCredentialHeader(new GroupSendCredentialPresentation(Base64.getDecoder().decode(header)));
|
||||
} catch (InvalidInputException | IllegalArgumentException e) {
|
||||
// Base64 throws IllegalArgumentException; GroupSendCredentialPresentation ctor throws InvalidInputException
|
||||
throw new WebApplicationException(e, Status.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,8 +18,6 @@ import java.util.Optional;
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public class OptionalAccess {
|
||||
|
||||
public static final String UNIDENTIFIED = "Unidentified-Access-Key";
|
||||
|
||||
public static void verify(Optional<Account> requestAccount,
|
||||
Optional<Anonymous> accessKey,
|
||||
Optional<Account> targetAccount,
|
||||
|
||||
@@ -61,6 +61,7 @@ import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.storage.KeysManager;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -206,7 +207,7 @@ public class KeysController {
|
||||
name = "Retry-After",
|
||||
description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed"))
|
||||
public PreKeyResponse getDeviceKeys(@Auth Optional<AuthenticatedAccount> auth,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
|
||||
@Parameter(description="the account or phone-number identifier to retrieve keys for")
|
||||
@PathParam("identifier") ServiceIdentifier targetIdentifier,
|
||||
|
||||
@@ -8,6 +8,8 @@ import static com.codahale.metrics.MetricRegistry.name;
|
||||
|
||||
import com.codahale.metrics.annotation.Timed;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.dropwizard.auth.Auth;
|
||||
@@ -52,6 +54,7 @@ import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.HeaderParam;
|
||||
import javax.ws.rs.NotAuthorizedException;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.PUT;
|
||||
@@ -67,13 +70,17 @@ import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;
|
||||
import org.signal.libsignal.protocol.ServiceId;
|
||||
import org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage.Recipient;
|
||||
import org.signal.libsignal.protocol.util.Pair;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.Anonymous;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.CombinedUnidentifiedSenderAccessKeys;
|
||||
import org.whispersystems.textsecuregcm.auth.GroupSendCredentialHeader;
|
||||
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
@@ -113,6 +120,7 @@ import org.whispersystems.textsecuregcm.storage.MessagesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import org.whispersystems.textsecuregcm.websocket.WebSocketConnection;
|
||||
import org.whispersystems.websocket.Stories;
|
||||
@@ -148,6 +156,7 @@ public class MessageController {
|
||||
private final ReportSpamTokenProvider reportSpamTokenProvider;
|
||||
private final ClientReleaseManager clientReleaseManager;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
private final ServerSecretParams serverSecretParams;
|
||||
|
||||
private static final int MAX_FETCH_ACCOUNT_CONCURRENCY = 8;
|
||||
|
||||
@@ -188,7 +197,8 @@ public class MessageController {
|
||||
Scheduler messageDeliveryScheduler,
|
||||
@Nonnull ReportSpamTokenProvider reportSpamTokenProvider,
|
||||
final ClientReleaseManager clientReleaseManager,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||
final ServerSecretParams serverSecretParams) {
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.messageByteLimitEstimator = messageByteLimitEstimator;
|
||||
this.messageSender = messageSender;
|
||||
@@ -202,6 +212,7 @@ public class MessageController {
|
||||
this.reportSpamTokenProvider = reportSpamTokenProvider;
|
||||
this.clientReleaseManager = clientReleaseManager;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
this.serverSecretParams = serverSecretParams;
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -211,7 +222,7 @@ public class MessageController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@FilterSpam
|
||||
public Response sendMessage(@Auth Optional<AuthenticatedAccount> source,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@PathParam("destination") ServiceIdentifier destinationIdentifier,
|
||||
@QueryParam("story") boolean isStory,
|
||||
@@ -372,6 +383,7 @@ public class MessageController {
|
||||
private Map<ServiceIdentifier, MultiRecipientDeliveryData> buildRecipientMap(
|
||||
SealedSenderMultiRecipientMessage multiRecipientMessage, boolean isStory) {
|
||||
return Flux.fromIterable(multiRecipientMessage.getRecipients().entrySet())
|
||||
.switchIfEmpty(Flux.error(BadRequestException::new))
|
||||
.map(e -> Tuples.of(ServiceIdentifier.fromLibsignal(e.getKey()), e.getValue()))
|
||||
.flatMap(
|
||||
t -> Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(t.getT1()))
|
||||
@@ -406,7 +418,9 @@ public class MessageController {
|
||||
""")
|
||||
@ApiResponse(responseCode="200", description="Message was successfully sent to all recipients", useReturnTypeSchema=true)
|
||||
@ApiResponse(responseCode="400", description="The envelope specified delivery to the same recipient device multiple times")
|
||||
@ApiResponse(responseCode="401", description="The message is not a story and the unauthorized access key is incorrect")
|
||||
@ApiResponse(
|
||||
responseCode="401",
|
||||
description="The message is not a story and the unauthorized access key or group send credential is missing or incorrect")
|
||||
@ApiResponse(
|
||||
responseCode="404",
|
||||
description="The message is not a story and some of the recipient service IDs do not correspond to registered Signal users")
|
||||
@@ -416,10 +430,14 @@ public class MessageController {
|
||||
@ApiResponse(
|
||||
responseCode = "410", description = "Mismatched registration ids supplied for some recipient devices",
|
||||
content = @Content(schema = @Schema(implementation = AccountStaleDevices[].class)))
|
||||
|
||||
public Response sendMultiRecipientMessage(
|
||||
@Parameter(description="The bitwise xor of the unidentified access keys for every recipient of the message")
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||
@Deprecated
|
||||
@Parameter(description="The bitwise xor of the unidentified access keys for every recipient of the message. Will be replaced with group send credentials")
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||
|
||||
@Parameter(description="A group send credential covering all (included and excluded) recipients of the message. Must not be combined with `Unidentified-Access-Key` or set on a story message.")
|
||||
@HeaderParam(HeaderUtils.GROUP_SEND_CREDENTIAL)
|
||||
@Nullable GroupSendCredentialHeader groupSendCredential,
|
||||
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
|
||||
@@ -436,14 +454,31 @@ public class MessageController {
|
||||
@QueryParam("story") boolean isStory,
|
||||
@Parameter(description="The sealed-sender multi-recipient message payload as serialized by libsignal")
|
||||
@NotNull SealedSenderMultiRecipientMessage multiRecipientMessage) throws RateLimitExceededException {
|
||||
if (groupSendCredential == null && accessKeys == null && !isStory) {
|
||||
throw new NotAuthorizedException("A group send credential or unidentified access key is required for non-story messages");
|
||||
}
|
||||
if (groupSendCredential != null) {
|
||||
if (accessKeys != null) {
|
||||
throw new BadRequestException("Only one of group send credential and unidentified access key may be provided");
|
||||
} else if (isStory) {
|
||||
throw new BadRequestExcpetion("Stories should not provide a group send credential");
|
||||
}
|
||||
}
|
||||
|
||||
if (groupSendCredential != null) {
|
||||
// Group send credentials are checked before we even attempt to resolve any accounts, since
|
||||
// the lists of service IDs in the envelope are all that we need to check against
|
||||
checkGroupSendCredential(
|
||||
multiRecipientMessage.getRecipients().keySet(), multiRecipientMessage.getExcludedRecipients(), groupSendCredential);
|
||||
}
|
||||
|
||||
final Map<ServiceIdentifier, MultiRecipientDeliveryData> recipients = buildRecipientMap(multiRecipientMessage, isStory);
|
||||
|
||||
// Stories will be checked by the client; we bypass access checks here for stories.
|
||||
if (!isStory) {
|
||||
// Access keys are checked against the UAK in the resolved accounts, so we have to check after resolving accounts above.
|
||||
// Group send credentials are checked earlier; for stories, we don't check permissions at all because only clients check them
|
||||
if (groupSendCredential == null && !isStory) {
|
||||
checkAccessKeys(accessKeys, recipients.values());
|
||||
}
|
||||
|
||||
// We might filter out all the recipients of a story (if none exist).
|
||||
// In this case there is no error so we should just return 200 now.
|
||||
if (isStory) {
|
||||
@@ -556,12 +591,28 @@ public class MessageController {
|
||||
return Response.ok(new SendMultiRecipientMessageResponse(uuids404)).build();
|
||||
}
|
||||
|
||||
private void checkAccessKeys(final CombinedUnidentifiedSenderAccessKeys accessKeys, final Collection<MultiRecipientDeliveryData> destinations) {
|
||||
// We should not have null access keys when checking access; bail out early.
|
||||
if (accessKeys == null) {
|
||||
throw new WebApplicationException(Status.UNAUTHORIZED);
|
||||
private void checkGroupSendCredential(
|
||||
final Collection<ServiceId> recipients,
|
||||
final Collection<ServiceId> excludedRecipients,
|
||||
final @NotNull GroupSendCredentialHeader groupSendCredential) {
|
||||
try {
|
||||
// A group send credential covers *every* group member except the sender. However, clients
|
||||
// don't always want to actually send to every recipient in the same multi-send (most
|
||||
// commonly because a new member needs an SKDM first, but also could be because the sender
|
||||
// has blocked someone). So we check the group send credential against the combination of
|
||||
// the actual recipients and the supplied list of "excluded" recipients, accounts the
|
||||
// sender knows are part of the credential but doesn't want to send to right now.
|
||||
groupSendCredential.presentation().verify(
|
||||
Lists.newArrayList(Iterables.concat(recipients, excludedRecipients)),
|
||||
serverSecretParams);
|
||||
} catch (VerificationFailedException e) {
|
||||
throw new NotAuthorizedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAccessKeys(
|
||||
final @NotNull CombinedUnidentifiedSenderAccessKeys accessKeys,
|
||||
final Collection<MultiRecipientDeliveryData> destinations) {
|
||||
final int keyLength = UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH;
|
||||
final byte[] combinedUnidentifiedAccessKeys = destinations.stream()
|
||||
.map(MultiRecipientDeliveryData::account)
|
||||
|
||||
@@ -94,6 +94,7 @@ import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ProfilesManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VersionedProfile;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import org.whispersystems.textsecuregcm.util.ProfileHelper;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
@@ -226,7 +227,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}/{version}")
|
||||
public VersionedProfileResponse getProfile(
|
||||
@Auth Optional<AuthenticatedAccount> auth,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
|
||||
@PathParam("version") String version)
|
||||
@@ -246,7 +247,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}/{version}/{credentialRequest}")
|
||||
public CredentialProfileResponse getProfile(
|
||||
@Auth Optional<AuthenticatedAccount> auth,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@PathParam("identifier") AciServiceIdentifier accountIdentifier,
|
||||
@PathParam("version") String version,
|
||||
@@ -276,7 +277,7 @@ public class ProfileController {
|
||||
@Path("/{identifier}")
|
||||
public BaseProfileResponse getUnversionedProfile(
|
||||
@Auth Optional<AuthenticatedAccount> auth,
|
||||
@HeaderParam(OptionalAccess.UNIDENTIFIED) Optional<Anonymous> accessKey,
|
||||
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
|
||||
@Context ContainerRequestContext containerRequestContext,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent,
|
||||
@PathParam("identifier") ServiceIdentifier identifier,
|
||||
|
||||
@@ -10,6 +10,8 @@ import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import org.signal.libsignal.protocol.ServiceId;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,11 @@ public record AciServiceIdentifier(UUID uuid) implements ServiceIdentifier {
|
||||
return byteBuffer.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceId.Aci toLibsignal() {
|
||||
return new ServiceId.Aci(uuid);
|
||||
}
|
||||
|
||||
public static AciServiceIdentifier valueOf(final String string) {
|
||||
return new AciServiceIdentifier(
|
||||
UUID.fromString(string.startsWith(IDENTITY_TYPE.getStringPrefix())
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
package org.whispersystems.textsecuregcm.identity;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import org.signal.libsignal.protocol.ServiceId;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
@@ -51,6 +53,11 @@ public record PniServiceIdentifier(UUID uuid) implements ServiceIdentifier {
|
||||
return byteBuffer.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServiceId.Pni toLibsignal() {
|
||||
return new ServiceId.Pni(uuid);
|
||||
}
|
||||
|
||||
public static PniServiceIdentifier valueOf(final String string) {
|
||||
if (!string.startsWith(IDENTITY_TYPE.getStringPrefix())) {
|
||||
throw new IllegalArgumentException("PNI account identifier did not start with \"PNI:\" prefix");
|
||||
|
||||
@@ -81,4 +81,6 @@ public interface ServiceIdentifier {
|
||||
}
|
||||
throw new IllegalArgumentException("unknown libsignal ServiceId type");
|
||||
}
|
||||
|
||||
ServiceId toLibsignal();
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ public final class HeaderUtils {
|
||||
|
||||
public static final String TIMESTAMP_HEADER = "X-Signal-Timestamp";
|
||||
|
||||
public static final String UNIDENTIFIED_ACCESS_KEY = "Unidentified-Access-Key";
|
||||
|
||||
public static final String GROUP_SEND_CREDENTIAL = "Group-Send-Credential";
|
||||
|
||||
private HeaderUtils() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user