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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 468 additions and 123 deletions

View File

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

View File

@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -30,18 +31,26 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue; import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam; import javax.ws.rs.HeaderParam;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.signal.libsignal.protocol.IdentityKey; 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.Anonymous;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;
import org.whispersystems.textsecuregcm.auth.OptionalAccess; import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.entities.CheckKeysRequest; import org.whispersystems.textsecuregcm.entities.CheckKeysRequest;
import org.whispersystems.textsecuregcm.entities.ECPreKey; import org.whispersystems.textsecuregcm.entities.ECPreKey;
@ -74,6 +83,8 @@ public class KeysController {
private final RateLimiters rateLimiters; private final RateLimiters rateLimiters;
private final KeysManager keysManager; private final KeysManager keysManager;
private final AccountsManager accounts; 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 GET_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, "getKeys");
private static final String STORE_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, "storeKeys"); 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]; 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.rateLimiters = rateLimiters;
this.keysManager = keysManager; this.keysManager = keysManager;
this.accounts = accounts; this.accounts = accounts;
this.serverSecretParams = serverSecretParams;
this.clock = clock;
} }
@GET @GET
@ -298,7 +311,8 @@ public class KeysController {
@Operation(summary = "Fetch public keys for another user", @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") 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 = "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 = "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( @ApiResponse(responseCode = "429", description = "Rate limit exceeded.", headers = @Header(
name = "Retry-After", name = "Retry-After",
@ -306,6 +320,7 @@ public class KeysController {
public PreKeyResponse getDeviceKeys( public PreKeyResponse getDeviceKeys(
@ReadOnly @Auth Optional<AuthenticatedAccount> auth, @ReadOnly @Auth Optional<AuthenticatedAccount> auth,
@HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey, @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") @Parameter(description="the account or phone-number identifier to retrieve keys for")
@PathParam("identifier") ServiceIdentifier targetIdentifier, @PathParam("identifier") ServiceIdentifier targetIdentifier,
@ -316,20 +331,27 @@ public class KeysController {
@HeaderParam(HttpHeaders.USER_AGENT) String userAgent) @HeaderParam(HttpHeaders.USER_AGENT) String userAgent)
throws RateLimitExceededException { throws RateLimitExceededException {
if (auth.isEmpty() && accessKey.isEmpty()) { if (auth.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {
throw new WebApplicationException(Response.Status.UNAUTHORIZED); throw new WebApplicationException(Response.Status.UNAUTHORIZED);
} }
final Optional<Account> account = auth.map(AuthenticatedAccount::getAccount); final Optional<Account> account = auth.map(AuthenticatedAccount::getAccount);
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);
final Account target; if (groupSendToken.isPresent()) {
{ if (auth.isPresent() || accessKey.isPresent()) {
final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier); 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); OptionalAccess.verify(account, accessKey, maybeTarget, deviceId);
target = maybeTarget.orElseThrow();
} }
final Account target = maybeTarget.orElseThrow(NotFoundException::new);
if (account.isPresent()) { if (account.isPresent()) {
rateLimiters.getPreKeysLimiter().validate( 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 io.grpc.Status;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import org.signal.chat.keys.CheckIdentityKeyRequest; import org.signal.chat.keys.CheckIdentityKeyRequest;
import org.signal.chat.keys.CheckIdentityKeyResponse; import org.signal.chat.keys.CheckIdentityKeyResponse;
import org.signal.chat.keys.GetPreKeysAnonymousRequest; import org.signal.chat.keys.GetPreKeysAnonymousRequest;
import org.signal.chat.keys.GetPreKeysResponse; import org.signal.chat.keys.GetPreKeysResponse;
import org.signal.chat.keys.ReactorKeysAnonymousGrpc; import org.signal.chat.keys.ReactorKeysAnonymousGrpc;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier; import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.KeysManager; import org.whispersystems.textsecuregcm.storage.KeysManager;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@ -28,11 +33,14 @@ public class KeysAnonymousGrpcService extends ReactorKeysAnonymousGrpc.KeysAnony
private final AccountsManager accountsManager; private final AccountsManager accountsManager;
private final KeysManager keysManager; 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.accountsManager = accountsManager;
this.keysManager = keysManager; this.keysManager = keysManager;
} this.groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, clock);
}
@Override @Override
public Mono<GetPreKeysResponse> getPreKeys(final GetPreKeysAnonymousRequest request) { 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()); final byte deviceId = DeviceIdUtil.validate(request.getRequest().getDeviceId());
return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier)) return switch (request.getAuthorizationCase()) {
.flatMap(Mono::justOrEmpty) case GROUP_SEND_TOKEN ->
.switchIfEmpty(Mono.error(Status.UNAUTHENTICATED.asException())) groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), List.of(serviceIdentifier))
.flatMap(targetAccount -> .then(lookUpAccount(serviceIdentifier, Status.NOT_FOUND))
UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, request.getUnidentifiedAccessKey().toByteArray()) .flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager));
? KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier.identityType(), deviceId, keysManager) case UNIDENTIFIED_ACCESS_KEY ->
: Mono.error(Status.UNAUTHENTICATED.asException())); 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 @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) { private static boolean fingerprintMatches(final IdentityKey identityKey, final byte[] fingerprint) {
final byte[] digest; final byte[] digest;
try { try {

View File

@ -164,9 +164,19 @@ message GetPreKeysAnonymousRequest {
GetPreKeysRequest request = 1; GetPreKeysRequest request = 1;
/** /**
* The unidentified access key (UAK) for the targeted account. * A means to authorize the request.
*/ */
bytes unidentified_access_key = 2; oneof authorization {
/**
* The unidentified access key (UAK) for the targeted account.
*/
bytes unidentified_access_key = 2;
/**
* A group send endorsement token for the targeted account.
*/
bytes group_send_token = 3;
}
} }
message GetPreKeysResponse { message GetPreKeysResponse {

View File

@ -29,13 +29,18 @@ import java.nio.ByteBuffer;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalInt; import java.util.OptionalInt;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import javax.ws.rs.client.Entity; import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import org.glassfish.jersey.server.ServerProperties; import org.glassfish.jersey.server.ServerProperties;
@ -52,6 +57,7 @@ import org.mockito.ArgumentCaptor;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.entities.CheckKeysRequest; import org.whispersystems.textsecuregcm.entities.CheckKeysRequest;
import org.whispersystems.textsecuregcm.entities.ECPreKey; import org.whispersystems.textsecuregcm.entities.ECPreKey;
@ -64,6 +70,7 @@ import org.whispersystems.textsecuregcm.entities.SignedPreKey;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
@ -79,6 +86,7 @@ import org.whispersystems.textsecuregcm.tests.util.KeysHelper;
import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; import org.whispersystems.textsecuregcm.util.ByteArrayAdapter;
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
import org.whispersystems.textsecuregcm.util.HeaderUtils; import org.whispersystems.textsecuregcm.util.HeaderUtils;
import org.whispersystems.textsecuregcm.util.TestClock;
@ExtendWith(DropwizardExtensionsSupport.class) @ExtendWith(DropwizardExtensionsSupport.class)
class KeysControllerTest { class KeysControllerTest {
@ -86,8 +94,13 @@ class KeysControllerTest {
private static final String EXISTS_NUMBER = "+14152222222"; private static final String EXISTS_NUMBER = "+14152222222";
private static final UUID EXISTS_UUID = UUID.randomUUID(); private static final UUID EXISTS_UUID = UUID.randomUUID();
private static final UUID EXISTS_PNI = UUID.randomUUID(); private static final UUID EXISTS_PNI = UUID.randomUUID();
private static final AciServiceIdentifier EXISTS_ACI = new AciServiceIdentifier(EXISTS_UUID);
private static final UUID OTHER_UUID = UUID.randomUUID();
private static final AciServiceIdentifier OTHER_ACI = new AciServiceIdentifier(OTHER_UUID);
private static final UUID NOT_EXISTS_UUID = UUID.randomUUID(); private static final UUID NOT_EXISTS_UUID = UUID.randomUUID();
private static final AciServiceIdentifier NOT_EXISTS_ACI = new AciServiceIdentifier(NOT_EXISTS_UUID);
private static final byte SAMPLE_DEVICE_ID = 1; private static final byte SAMPLE_DEVICE_ID = 1;
private static final byte SAMPLE_DEVICE_ID2 = 2; private static final byte SAMPLE_DEVICE_ID2 = 2;
@ -136,6 +149,10 @@ class KeysControllerTest {
private static final RateLimiters rateLimiters = mock(RateLimiters.class); private static final RateLimiters rateLimiters = mock(RateLimiters.class);
private static final RateLimiter rateLimiter = mock(RateLimiter.class ); private static final RateLimiter rateLimiter = mock(RateLimiter.class );
private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate();
private static final TestClock clock = TestClock.now();
private static final ResourceExtension resources = ResourceExtension.builder() private static final ResourceExtension resources = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
.addProvider(AuthHelper.getAuthFilter()) .addProvider(AuthHelper.getAuthFilter())
@ -143,7 +160,7 @@ class KeysControllerTest {
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class)) .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class))
.setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new ServerRejectedExceptionMapper()) .addResource(new ServerRejectedExceptionMapper())
.addResource(new KeysController(rateLimiters, KEYS, accounts)) .addResource(new KeysController(rateLimiters, KEYS, accounts, serverSecretParams, clock))
.addResource(new RateLimitExceededExceptionMapper()) .addResource(new RateLimitExceededExceptionMapper())
.build(); .build();
@ -183,6 +200,8 @@ class KeysControllerTest {
@BeforeEach @BeforeEach
void setup() { void setup() {
clock.unpin();
sampleDevice = mock(Device.class); sampleDevice = mock(Device.class);
final Device sampleDevice2 = mock(Device.class); final Device sampleDevice2 = mock(Device.class);
final Device sampleDevice3 = mock(Device.class); final Device sampleDevice3 = mock(Device.class);
@ -529,6 +548,68 @@ class KeysControllerTest {
verifyNoMoreInteractions(KEYS); verifyNoMoreInteractions(KEYS);
} }
@ParameterizedTest
@MethodSource
void testGetKeysWithGroupSendEndorsement(
ServiceIdentifier target, ServiceIdentifier authorizedTarget, Duration timeLeft, boolean includeUak, int expectedResponse) throws Exception {
final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);
clock.pin(expiration.minus(timeLeft));
Invocation.Builder builder = resources.getJerseyTest()
.target(String.format("/v2/keys/%s/1", target.toServiceIdentifierString()))
.queryParam("pq", "true")
.request()
.header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedTarget), expiration));
if (includeUak) {
builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes()));
}
Response response = builder.get();
assertThat(response.getStatus()).isEqualTo(expectedResponse);
if (expectedResponse == 200) {
PreKeyResponse result = response.readEntity(PreKeyResponse.class);
assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.ACI));
assertThat(result.getDevicesCount()).isEqualTo(1);
assertEquals(SAMPLE_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());
assertEquals(SAMPLE_PQ_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey());
assertEquals(SAMPLE_SIGNED_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());
verify(KEYS).takeEC(EXISTS_UUID, SAMPLE_DEVICE_ID);
verify(KEYS).takePQ(EXISTS_UUID, SAMPLE_DEVICE_ID);
verify(KEYS).getEcSignedPreKey(EXISTS_UUID, SAMPLE_DEVICE_ID);
}
verifyNoMoreInteractions(KEYS);
}
private static Stream<Arguments> testGetKeysWithGroupSendEndorsement() {
return Stream.of(
// valid endorsement
Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(1), false, 200),
// expired endorsement, not authorized
Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(-1), false, 401),
// endorsement for the wrong recipient, not authorized
Arguments.of(EXISTS_ACI, OTHER_ACI, Duration.ofHours(1), false, 401),
// expired endorsement for the wrong recipient, not authorized
Arguments.of(EXISTS_ACI, OTHER_ACI, Duration.ofHours(-1), false, 401),
// valid endorsement for the right recipient but they aren't registered, not found
Arguments.of(NOT_EXISTS_ACI, NOT_EXISTS_ACI, Duration.ofHours(1), false, 404),
// expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found)
Arguments.of(NOT_EXISTS_ACI, NOT_EXISTS_ACI, Duration.ofHours(-1), false, 401),
// valid endorsement but also a UAK, bad request
Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(1), true, 400));
}
@Test @Test
void testNoDevices() { void testNoDevices() {

View File

@ -433,7 +433,7 @@ class MessageControllerTest {
.queryParam("story", story) .queryParam("story", story)
.request() .request()
.header(HeaderUtils.GROUP_SEND_TOKEN, .header(HeaderUtils.GROUP_SEND_TOKEN,
validGroupSendTokenHeader(List.of(authorizedRecipient), expiration)); AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedRecipient), expiration));
if (includeUak) { if (includeUak) {
builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES)); builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES));
@ -1351,8 +1351,8 @@ class MessageControllerTest {
.queryParam("urgent", false) .queryParam("urgent", false)
.request() .request()
.header(HttpHeaders.USER_AGENT, "FIXME") .header(HttpHeaders.USER_AGENT, "FIXME")
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(
List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) serverSecretParams, List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
.put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE)); .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
assertThat("Unexpected response", response.getStatus(), is(equalTo(200))); assertThat("Unexpected response", response.getStatus(), is(equalTo(200)));
@ -1389,8 +1389,8 @@ class MessageControllerTest {
.queryParam("urgent", false) .queryParam("urgent", false)
.request() .request()
.header(HttpHeaders.USER_AGENT, "FIXME") .header(HttpHeaders.USER_AGENT, "FIXME")
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(
List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) serverSecretParams, List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
.put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE)); .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
assertThat("Unexpected response", response.getStatus(), is(equalTo(401))); assertThat("Unexpected response", response.getStatus(), is(equalTo(401)));
@ -1419,39 +1419,14 @@ class MessageControllerTest {
.queryParam("urgent", false) .queryParam("urgent", false)
.request() .request()
.header(HttpHeaders.USER_AGENT, "FIXME") .header(HttpHeaders.USER_AGENT, "FIXME")
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader( .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(
List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z"))) serverSecretParams, List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
.put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE)); .put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
assertThat("Unexpected response", response.getStatus(), is(equalTo(401))); assertThat("Unexpected response", response.getStatus(), is(equalTo(401)));
verifyNoMoreInteractions(messageSender); verifyNoMoreInteractions(messageSender);
} }
private String validGroupSendTokenHeader(List<ServiceIdentifier> recipients, Instant expiration) throws Exception {
final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();
final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]);
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams);
final ServiceId.Aci sender = new ServiceId.Aci(UUID.randomUUID());
List<ServiceId> groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream().map(ServiceIdentifier::toLibsignal)).toList();
List<UuidCiphertext> groupCiphertexts = groupPlaintexts.stream()
.map(clientZkGroupCipher::encrypt)
.toList();
GroupSendDerivedKeyPair keyPair = GroupSendDerivedKeyPair.forExpiration(expiration, serverSecretParams);
GroupSendEndorsementsResponse endorsementsResponse =
GroupSendEndorsementsResponse.issue(groupCiphertexts, keyPair);
ReceivedEndorsements endorsements =
endorsementsResponse.receive(
groupPlaintexts,
sender,
expiration.minus(Duration.ofDays(1)),
groupSecretParams,
serverPublicParams);
GroupSendFullToken token = endorsements.combinedEndorsement().toFullToken(groupSecretParams, expiration);
return Base64.getEncoder().encodeToString(token.serialize());
}
@ParameterizedTest @ParameterizedTest
@ValueSource(booleans = {true, false}) @ValueSource(booleans = {true, false})
void testMultiRecipientRedisBombProtection(final boolean useExplicitIdentifier) throws Exception { void testMultiRecipientRedisBombProtection(final boolean useExplicitIdentifier) throws Exception {

View File

@ -6,23 +6,27 @@
package org.whispersystems.textsecuregcm.grpc; package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyByte; import static org.mockito.ArgumentMatchers.anyByte;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException; import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import io.grpc.Status; import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
@ -41,6 +45,7 @@ import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil; import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
import org.whispersystems.textsecuregcm.entities.ECPreKey; import org.whispersystems.textsecuregcm.entities.ECPreKey;
import org.whispersystems.textsecuregcm.entities.ECSignedPreKey; import org.whispersystems.textsecuregcm.entities.ECSignedPreKey;
@ -52,7 +57,10 @@ import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
import org.whispersystems.textsecuregcm.storage.KeysManager; import org.whispersystems.textsecuregcm.storage.KeysManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.tests.util.DevicesHelper;
import org.whispersystems.textsecuregcm.tests.util.KeysHelper; import org.whispersystems.textsecuregcm.tests.util.KeysHelper;
import org.whispersystems.textsecuregcm.util.TestClock;
import org.whispersystems.textsecuregcm.util.TestRandomUtil; import org.whispersystems.textsecuregcm.util.TestRandomUtil;
import org.whispersystems.textsecuregcm.util.UUIDUtil; import org.whispersystems.textsecuregcm.util.UUIDUtil;
import org.whispersystems.textsecuregcm.util.Util; import org.whispersystems.textsecuregcm.util.Util;
@ -60,6 +68,9 @@ import reactor.core.publisher.Flux;
class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcService, KeysAnonymousGrpc.KeysAnonymousBlockingStub> { class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcService, KeysAnonymousGrpc.KeysAnonymousBlockingStub> {
private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();
private static final TestClock CLOCK = TestClock.now();
@Mock @Mock
private AccountsManager accountsManager; private AccountsManager accountsManager;
@ -68,71 +79,54 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
@Override @Override
protected KeysAnonymousGrpcService createServiceBeforeEachTest() { protected KeysAnonymousGrpcService createServiceBeforeEachTest() {
return new KeysAnonymousGrpcService(accountsManager, keysManager); return new KeysAnonymousGrpcService(accountsManager, keysManager, SERVER_SECRET_PARAMS, CLOCK);
} }
@Test @Test
void getPreKeys() { void getPreKeysUnidentifiedAccessKey() {
final Account targetAccount = mock(Account.class); final Account targetAccount = mock(Account.class);
final Device targetDevice = mock(Device.class);
final Device targetDevice = DevicesHelper.createDevice(Device.PRIMARY_ID);
when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(targetDevice));
final ECKeyPair identityKeyPair = Curve.generateKeyPair(); final ECKeyPair identityKeyPair = Curve.generateKeyPair();
final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey()); final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());
final UUID identifier = UUID.randomUUID(); final UUID uuid = UUID.randomUUID();
final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);
final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH); final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);
when(targetDevice.getId()).thenReturn(Device.PRIMARY_ID);
when(targetDevice.isEnabled()).thenReturn(true);
when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(targetDevice));
when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(identifier); when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);
when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey); when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey);
when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(identifier))) when(accountsManager.getByServiceIdentifierAsync(identifier))
.thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount))); .thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount)));
final ECPreKey ecPreKey = new ECPreKey(1, Curve.generateKeyPair().getPublicKey()); final ECPreKey ecPreKey = new ECPreKey(1, Curve.generateKeyPair().getPublicKey());
final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair); final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair);
final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair); final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair);
when(keysManager.takeEC(identifier, Device.PRIMARY_ID)) when(keysManager.takeEC(uuid, Device.PRIMARY_ID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(ecPreKey))); .thenReturn(CompletableFuture.completedFuture(Optional.of(ecPreKey)));
when(keysManager.takePQ(identifier, Device.PRIMARY_ID)) when(keysManager.takePQ(uuid, Device.PRIMARY_ID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(kemSignedPreKey))); .thenReturn(CompletableFuture.completedFuture(Optional.of(kemSignedPreKey)));
when(keysManager.getEcSignedPreKey(identifier, Device.PRIMARY_ID)) when(keysManager.getEcSignedPreKey(uuid, Device.PRIMARY_ID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(ecSignedPreKey))); .thenReturn(CompletableFuture.completedFuture(Optional.of(ecSignedPreKey)));
final GetPreKeysResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() final GetPreKeysResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
.setRequest(GetPreKeysRequest.newBuilder() .setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifier.newBuilder() .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI) .setDeviceId(Device.PRIMARY_ID))
.setUuid(UUIDUtil.toByteString(identifier))
.build())
.setDeviceId(Device.PRIMARY_ID)
.build())
.build()); .build());
final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder() final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder()
.setIdentityKey(ByteString.copyFrom(identityKey.serialize())) .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.putPreKeys(Device.PRIMARY_ID, GetPreKeysResponse.PreKeyBundle.newBuilder() .putPreKeys(Device.PRIMARY_ID, GetPreKeysResponse.PreKeyBundle.newBuilder()
.setEcOneTimePreKey(EcPreKey.newBuilder() .setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey))
.setKeyId(ecPreKey.keyId()) .setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey))
.setPublicKey(ByteString.copyFrom(ecPreKey.serializedPublicKey())) .setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey))
.build())
.setEcSignedPreKey(EcSignedPreKey.newBuilder()
.setKeyId(ecSignedPreKey.keyId())
.setPublicKey(ByteString.copyFrom(ecSignedPreKey.serializedPublicKey()))
.setSignature(ByteString.copyFrom(ecSignedPreKey.signature()))
.build())
.setKemOneTimePreKey(KemSignedPreKey.newBuilder()
.setKeyId(kemSignedPreKey.keyId())
.setPublicKey(ByteString.copyFrom(kemSignedPreKey.serializedPublicKey()))
.setSignature(ByteString.copyFrom(kemSignedPreKey.signature()))
.build())
.build()) .build())
.build(); .build();
@ -140,50 +134,177 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
} }
@Test @Test
void getPreKeysIncorrectUnidentifiedAccessKey() { void getPreKeysGroupSendEndorsement() throws Exception {
final Account targetAccount = mock(Account.class); final Account targetAccount = mock(Account.class);
final Device targetDevice = DevicesHelper.createDevice(Device.PRIMARY_ID);
when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(targetDevice));
final ECKeyPair identityKeyPair = Curve.generateKeyPair(); final ECKeyPair identityKeyPair = Curve.generateKeyPair();
final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey()); final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());
final UUID identifier = UUID.randomUUID(); final UUID uuid = UUID.randomUUID();
final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);
final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH); final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);
when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(targetAccount.getUuid()).thenReturn(identifier); when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);
when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey); when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey);
when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(identifier))) when(accountsManager.getByServiceIdentifierAsync(identifier))
.thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount))); .thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount)));
assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() final ECPreKey ecPreKey = new ECPreKey(1, Curve.generateKeyPair().getPublicKey());
final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair);
final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair);
when(keysManager.takeEC(uuid, Device.PRIMARY_ID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(ecPreKey)));
when(keysManager.takePQ(uuid, Device.PRIMARY_ID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(kemSignedPreKey)));
when(keysManager.getEcSignedPreKey(uuid, Device.PRIMARY_ID))
.thenReturn(CompletableFuture.completedFuture(Optional.of(ecSignedPreKey)));
// Expirations must be on day boundaries or libsignal will refuse to create or verify the token
final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);
CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet
final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(identifier), expiration);
final GetPreKeysResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()
.setGroupSendToken(ByteString.copyFrom(token))
.setRequest(GetPreKeysRequest.newBuilder() .setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifier.newBuilder() .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI) .setDeviceId(Device.PRIMARY_ID))
.setUuid(UUIDUtil.toByteString(identifier)) .build());
.build())
.setDeviceId(Device.PRIMARY_ID) final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder()
.setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
.putPreKeys(Device.PRIMARY_ID, GetPreKeysResponse.PreKeyBundle.newBuilder()
.setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey))
.setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey))
.setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey))
.build()) .build())
.build())); .build();
assertEquals(expectedResponse, response);
} }
@Test @Test
void getPreKeysAccountNotFound() { void getPreKeysNoAuth() {
when(accountsManager.getByServiceIdentifierAsync(any())) assertGetKeysFailure(Status.INVALID_ARGUMENT, GetPreKeysAnonymousRequest.newBuilder()
.setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))
.setDeviceId(Device.PRIMARY_ID))
.build());
verifyNoInteractions(accountsManager);
verifyNoInteractions(keysManager);
}
@Test
void getPreKeysIncorrectUnidentifiedAccessKey() {
final Account targetAccount = mock(Account.class);
final UUID uuid = UUID.randomUUID();
final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);
final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);
when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));
when(accountsManager.getByServiceIdentifierAsync(identifier))
.thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount)));
assertGetKeysFailure(Status.UNAUTHENTICATED, GetPreKeysAnonymousRequest.newBuilder()
.setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID()))
.setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))
.setDeviceId(Device.PRIMARY_ID))
.build());
verifyNoInteractions(keysManager);
}
@Test
void getPreKeysExpiredGroupSendEndorsement() throws Exception {
final UUID uuid = UUID.randomUUID();
final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);
// Expirations must be on day boundaries or libsignal will refuse to create or verify the token
final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);
CLOCK.pin(expiration.plus(Duration.ofHours(1))); // set time so our token is already expired
final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(identifier), expiration);
assertGetKeysFailure(Status.UNAUTHENTICATED, GetPreKeysAnonymousRequest.newBuilder()
.setGroupSendToken(ByteString.copyFrom(token))
.setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))
.setDeviceId(Device.PRIMARY_ID))
.build());
verifyNoInteractions(accountsManager);
verifyNoInteractions(keysManager);
}
@Test
void getPreKeysIncorrectGroupSendEndorsement() throws Exception {
final AciServiceIdentifier authorizedIdentifier = new AciServiceIdentifier(UUID.randomUUID());
final AciServiceIdentifier targetIdentifier = new AciServiceIdentifier(UUID.randomUUID());
// Expirations must be on day boundaries or libsignal will refuse to create or verify the token
final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);
CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet
final AciServiceIdentifier wrongAci = new AciServiceIdentifier(UUID.randomUUID());
final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(authorizedIdentifier), expiration);
assertGetKeysFailure(Status.UNAUTHENTICATED, GetPreKeysAnonymousRequest.newBuilder()
.setGroupSendToken(ByteString.copyFrom(token))
.setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(targetIdentifier))
.setDeviceId(Device.PRIMARY_ID))
.build());
verifyNoInteractions(accountsManager);
verifyNoInteractions(keysManager);
}
@Test
void getPreKeysAccountNotFoundUnidentifiedAccessKey() {
final AciServiceIdentifier nonexistentAci = new AciServiceIdentifier(UUID.randomUUID());
when(accountsManager.getByServiceIdentifierAsync(nonexistentAci))
.thenReturn(CompletableFuture.completedFuture(Optional.empty())); .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
final StatusRuntimeException exception = assertGetKeysFailure(Status.UNAUTHENTICATED,
assertThrows(StatusRuntimeException.class, GetPreKeysAnonymousRequest.newBuilder()
() -> unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() .setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID()))
.setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID())) .setRequest(GetPreKeysRequest.newBuilder()
.setRequest(GetPreKeysRequest.newBuilder() .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci)))
.setTargetIdentifier(ServiceIdentifier.newBuilder() .build());
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)
.setUuid(UUIDUtil.toByteString(UUID.randomUUID()))
.build())
.build())
.build()));
assertEquals(Status.Code.UNAUTHENTICATED, exception.getStatus().getCode()); verifyNoInteractions(keysManager);
}
@Test
void getPreKeysAccountNotFoundGroupSendEndorsement() throws Exception {
final AciServiceIdentifier nonexistentAci = new AciServiceIdentifier(UUID.randomUUID());
// Expirations must be on day boundaries or libsignal will refuse to create or verify the token
final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);
CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet
final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(nonexistentAci), expiration);
when(accountsManager.getByServiceIdentifierAsync(nonexistentAci))
.thenReturn(CompletableFuture.completedFuture(Optional.empty()));
assertGetKeysFailure(Status.NOT_FOUND,
GetPreKeysAnonymousRequest.newBuilder()
.setGroupSendToken(ByteString.copyFrom(token))
.setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci)))
.build());
verifyNoInteractions(keysManager);
} }
@ParameterizedTest @ParameterizedTest
@ -203,16 +324,14 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(accountIdentifier))) when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(accountIdentifier)))
.thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount))); .thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount)));
assertStatusException(Status.NOT_FOUND, () -> unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder() assertGetKeysFailure(Status.NOT_FOUND, GetPreKeysAnonymousRequest.newBuilder()
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey)) .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
.setRequest(GetPreKeysRequest.newBuilder() .setRequest(GetPreKeysRequest.newBuilder()
.setTargetIdentifier(ServiceIdentifier.newBuilder() .setTargetIdentifier(ServiceIdentifier.newBuilder()
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI) .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)
.setUuid(UUIDUtil.toByteString(accountIdentifier)) .setUuid(UUIDUtil.toByteString(accountIdentifier)))
.build()) .setDeviceId(deviceId))
.setDeviceId(deviceId) .build());
.build())
.build()));
} }
@Test @Test
@ -291,4 +410,32 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
throw new AssertionError("All Java implementations must support SHA-256 MessageDigest algorithm", e); throw new AssertionError("All Java implementations must support SHA-256 MessageDigest algorithm", e);
} }
} }
private void assertGetKeysFailure(Status code, GetPreKeysAnonymousRequest request) {
assertStatusException(code, () -> unauthenticatedServiceStub().getPreKeys(request));
}
private static EcPreKey toGrpcEcPreKey(final ECPreKey preKey) {
return EcPreKey.newBuilder()
.setKeyId(preKey.keyId())
.setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize()))
.build();
}
private static EcSignedPreKey toGrpcEcSignedPreKey(final ECSignedPreKey preKey) {
return EcSignedPreKey.newBuilder()
.setKeyId(preKey.keyId())
.setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize()))
.setSignature(ByteString.copyFrom(preKey.signature()))
.build();
}
private static KemSignedPreKey toGrpcKemSignedPreKey(final KEMSignedPreKey preKey) {
return KemSignedPreKey.newBuilder()
.setKeyId(preKey.keyId())
.setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize()))
.setSignature(ByteString.copyFrom(preKey.signature()))
.build();
}
} }

View File

@ -18,6 +18,8 @@ import io.dropwizard.auth.PolymorphicAuthDynamicFeature;
import io.dropwizard.auth.basic.BasicCredentialAuthFilter; import io.dropwizard.auth.basic.BasicCredentialAuthFilter;
import io.dropwizard.auth.basic.BasicCredentials; import io.dropwizard.auth.basic.BasicCredentials;
import java.security.Principal; import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
@ -26,17 +28,31 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Random; import java.util.Random;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ServiceId;
import org.signal.libsignal.protocol.ecc.Curve; import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.zkgroup.ServerPublicParams;
import org.signal.libsignal.zkgroup.ServerSecretParams;
import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
import org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse.ReceivedEndorsements;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.IdentityType; import org.whispersystems.textsecuregcm.identity.IdentityType;
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier; import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.Device;
@ -317,4 +333,33 @@ public class AuthHelper {
EXTENSION_TEST_ACCOUNTS.clear(); EXTENSION_TEST_ACCOUNTS.clear();
} }
} }
public static byte[] validGroupSendToken(ServerSecretParams serverSecretParams, List<ServiceIdentifier> recipients, Instant expiration) throws Exception {
final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();
final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]);
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams);
final ServiceId.Aci sender = new ServiceId.Aci(UUID.randomUUID());
List<ServiceId> groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream().map(ServiceIdentifier::toLibsignal)).toList();
List<UuidCiphertext> groupCiphertexts = groupPlaintexts.stream()
.map(clientZkGroupCipher::encrypt)
.toList();
GroupSendDerivedKeyPair keyPair = GroupSendDerivedKeyPair.forExpiration(expiration, serverSecretParams);
GroupSendEndorsementsResponse endorsementsResponse =
GroupSendEndorsementsResponse.issue(groupCiphertexts, keyPair);
ReceivedEndorsements endorsements =
endorsementsResponse.receive(
groupPlaintexts,
sender,
expiration.minus(Duration.ofDays(1)),
groupSecretParams,
serverPublicParams);
GroupSendFullToken token = endorsements.combinedEndorsement().toFullToken(groupSecretParams, expiration);
return token.serialize();
}
public static String validGroupSendTokenHeader(ServerSecretParams serverSecretParams, List<ServiceIdentifier> recipients, Instant expiration) throws Exception {
return Base64.getEncoder().encodeToString(validGroupSendToken(serverSecretParams, recipients, expiration));
}
} }