Group Send Endorsement support for pre-key fetch endpoint

This commit is contained in:
Jonathan Klabunde Tomer
2024-04-19 15:40:46 -07:00
committed by GitHub
parent ab64828661
commit b8f64fe3d4
9 changed files with 468 additions and 123 deletions

View File

@@ -795,7 +795,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.intercept(requestAttributesInterceptor)
.intercept(new ProhibitAuthenticationInterceptor(clientConnectionManager))
.addService(new AccountsAnonymousGrpcService(accountsManager, rateLimiters))
.addService(new KeysAnonymousGrpcService(accountsManager, keysManager))
.addService(new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()))
.addService(new PaymentsGrpcService(currencyManager))
.addService(ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
.addService(new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkProfileOperations));
@@ -961,7 +961,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new DirectoryV2Controller(directoryV2CredentialsGenerator),
new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),
ReceiptCredentialPresentation::new),
new KeysController(rateLimiters, keysManager, accountsManager),
new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()),
new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender, receiptSender,
accountsManager, messagesManager, pushNotificationManager, reportMessageManager,
multiRecipientMessageExecutor, messageDeliveryScheduler, reportSpamTokenProvider, clientReleaseManager,

View File

@@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -30,18 +31,26 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.entities.CheckKeysRequest;
import org.whispersystems.textsecuregcm.entities.ECPreKey;
@@ -74,6 +83,8 @@ public class KeysController {
private final RateLimiters rateLimiters;
private final KeysManager keysManager;
private final AccountsManager accounts;
private final ServerSecretParams serverSecretParams;
private final Clock clock;
private static final String GET_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, "getKeys");
private static final String STORE_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, "storeKeys");
@@ -83,10 +94,12 @@ public class KeysController {
private static final CompletableFuture<?>[] EMPTY_FUTURE_ARRAY = new CompletableFuture[0];
public KeysController(RateLimiters rateLimiters, KeysManager keysManager, AccountsManager accounts) {
public KeysController(RateLimiters rateLimiters, KeysManager keysManager, AccountsManager accounts, ServerSecretParams serverSecretParams, Clock clock) {
this.rateLimiters = rateLimiters;
this.keysManager = keysManager;
this.accounts = accounts;
this.serverSecretParams = serverSecretParams;
this.clock = clock;
}
@GET
@@ -298,7 +311,8 @@ public class KeysController {
@Operation(summary = "Fetch public keys for another user",
description = "Retrieves the public identity key and available device prekeys for a specified account or phone-number identity")
@ApiResponse(responseCode = "200", description = "Indicates at least one prekey was available for at least one requested device.", useReturnTypeSchema = true)
@ApiResponse(responseCode = "401", description = "Account authentication check failed and unidentified-access key was not supplied or invalid.")
@ApiResponse(responseCode = "400", description = "A group send endorsement and other authorization (account authentication or unidentified-access key) were both provided.")
@ApiResponse(responseCode = "401", description = "Account authentication check failed and unidentified-access key or group send endorsement token was not supplied or invalid.")
@ApiResponse(responseCode = "404", description = "Requested identity or device does not exist, is not active, or has no available prekeys.")
@ApiResponse(responseCode = "429", description = "Rate limit exceeded.", headers = @Header(
name = "Retry-After",
@@ -306,6 +320,7 @@ public class KeysController {
public PreKeyResponse getDeviceKeys(
@ReadOnly @Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,
@HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,
@Parameter(description="the account or phone-number identifier to retrieve keys for")
@PathParam("identifier") ServiceIdentifier targetIdentifier,
@@ -316,20 +331,27 @@ public class KeysController {
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
throws RateLimitExceededException {
if (auth.isEmpty() && accessKey.isEmpty()) {
if (auth.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED);
}
final Optional<Account> account = auth.map(AuthenticatedAccount::getAccount);
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);
final Account target;
{
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);
if (groupSendToken.isPresent()) {
if (auth.isPresent() || accessKey.isPresent()) {
throw new BadRequestException();
}
try {
final GroupSendFullToken token = groupSendToken.get().token();
token.verify(List.of(targetIdentifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams));
} catch (VerificationFailedException e) {
throw new NotAuthorizedException(e);
}
} else {
OptionalAccess.verify(account, accessKey, maybeTarget, deviceId);
target = maybeTarget.orElseThrow();
}
final Account target = maybeTarget.orElseThrow(NotFoundException::new);
if (account.isPresent()) {
rateLimiters.getPreKeysLimiter().validate(

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.time.Clock;
import java.util.List;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import reactor.core.publisher.Mono;
public class GroupSendTokenUtil {
private final ServerSecretParams serverSecretParams;
private final Clock clock;
public GroupSendTokenUtil(final ServerSecretParams serverSecretParams, final Clock clock) {
this.serverSecretParams = serverSecretParams;
this.clock = clock;
}
public Mono<Void> checkGroupSendToken(final ByteString serializedGroupSendToken, List<ServiceIdentifier> serviceIdentifiers) {
try {
final GroupSendFullToken token = new GroupSendFullToken(serializedGroupSendToken.toByteArray());
final List<ServiceId> serviceIds = serviceIdentifiers.stream().map(ServiceIdentifier::toLibsignal).toList();
token.verify(serviceIds, clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams));
return Mono.empty();
} catch (InvalidInputException e) {
return Mono.error(Status.INVALID_ARGUMENT.asException());
} catch (VerificationFailedException e) {
return Mono.error(Status.UNAUTHENTICATED.asException());
}
}
}

View File

@@ -9,15 +9,20 @@ import com.google.protobuf.ByteString;
import io.grpc.Status;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.Arrays;
import java.util.List;
import org.signal.chat.keys.CheckIdentityKeyRequest;
import org.signal.chat.keys.CheckIdentityKeyResponse;
import org.signal.chat.keys.GetPreKeysAnonymousRequest;
import org.signal.chat.keys.GetPreKeysResponse;
import org.signal.chat.keys.ReactorKeysAnonymousGrpc;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.KeysManager;
import reactor.core.publisher.Flux;
@@ -28,11 +33,14 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony
private final AccountsManager accountsManager;
private final KeysManager keysManager;
private final GroupSendTokenUtil groupSendTokenUtil;
public KeysAnonymousGrpcService(final AccountsManager accountsManager, final KeysManager keysManager) {
public KeysAnonymousGrpcService(
final AccountsManager accountsManager, final KeysManager keysManager, final ServerSecretParams serverSecretParams, final Clock clock) {
this.accountsManager = accountsManager;
this.keysManager = keysManager;
}
this.groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, clock);
}
@Override
public Mono<GetPreKeysResponse> getPreKeys(final GetPreKeysAnonymousRequest request) {
@@ -41,13 +49,19 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony
final byte deviceId = DeviceIdUtil.validate(request.getRequest().getDeviceId());
return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier))
.flatMap(Mono::justOrEmpty)
.switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException()))
.flatMap(targetAccount ->
UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, request.getUnidentifiedAccessKey().toByteArray())
? KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager)
: Mono.error(Status.UNAUTHENTICATED.asException()));
return switch (request.getAuthorizationCase()) {
case GROUP_SEND_TOKEN ->
groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), List.of(serviceIdentifier))
.then(lookUpAccount(serviceIdentifier, Status.NOT_FOUND))
.flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager));
case UNIDENTIFIED_ACCESS_KEY ->
lookUpAccount(serviceIdentifier, Status.UNAUTHENTICATED)
.flatMap(targetAccount ->
UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, request.getUnidentifiedAccessKey().toByteArray())
? KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager)
: Mono.error(Status.UNAUTHENTICATED.asException()));
default -> Mono.error(Status.INVALID_ARGUMENT.asException());
};
}
@Override
@@ -69,6 +83,12 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony
);
}
private Mono<Account> lookUpAccount(final ServiceIdentifier serviceIdentifier, final Status onNotFound) {
return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier))
.flatMap(Mono::justOrEmpty)
.switchIfEmpty(Mono.error(onNotFound.asException()));
}
private static boolean fingerprintMatches(final IdentityKey identityKey, final byte[] fingerprint) {
final byte[] digest;
try {