Group Send Endorsement support for pre-key fetch endpoint
This commit is contained in:
parent
ab64828661
commit
b8f64fe3d4
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -164,9 +164,19 @@ message GetPreKeysAnonymousRequest {
|
|||
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 {
|
||||
|
|
|
@ -29,13 +29,18 @@ import java.nio.ByteBuffer;
|
|||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.ws.rs.client.Entity;
|
||||
import javax.ws.rs.client.Invocation;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
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.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.entities.CheckKeysRequest;
|
||||
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.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
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.CompletableFutureTestUtil;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
|
||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||
class KeysControllerTest {
|
||||
|
@ -86,8 +94,13 @@ class KeysControllerTest {
|
|||
private static final String EXISTS_NUMBER = "+14152222222";
|
||||
private static final UUID EXISTS_UUID = 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 AciServiceIdentifier NOT_EXISTS_ACI = new AciServiceIdentifier(NOT_EXISTS_UUID);
|
||||
|
||||
private static final byte SAMPLE_DEVICE_ID = 1;
|
||||
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 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()
|
||||
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
|
||||
.addProvider(AuthHelper.getAuthFilter())
|
||||
|
@ -143,7 +160,7 @@ class KeysControllerTest {
|
|||
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class))
|
||||
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
|
||||
.addResource(new ServerRejectedExceptionMapper())
|
||||
.addResource(new KeysController(rateLimiters, KEYS, accounts))
|
||||
.addResource(new KeysController(rateLimiters, KEYS, accounts, serverSecretParams, clock))
|
||||
.addResource(new RateLimitExceededExceptionMapper())
|
||||
.build();
|
||||
|
||||
|
@ -183,6 +200,8 @@ class KeysControllerTest {
|
|||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
clock.unpin();
|
||||
|
||||
sampleDevice = mock(Device.class);
|
||||
final Device sampleDevice2 = mock(Device.class);
|
||||
final Device sampleDevice3 = mock(Device.class);
|
||||
|
@ -529,6 +548,68 @@ class KeysControllerTest {
|
|||
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
|
||||
void testNoDevices() {
|
||||
|
||||
|
|
|
@ -433,7 +433,7 @@ class MessageControllerTest {
|
|||
.queryParam("story", story)
|
||||
.request()
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN,
|
||||
validGroupSendTokenHeader(List.of(authorizedRecipient), expiration));
|
||||
AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedRecipient), expiration));
|
||||
|
||||
if (includeUak) {
|
||||
builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES));
|
||||
|
@ -1351,8 +1351,8 @@ class MessageControllerTest {
|
|||
.queryParam("urgent", false)
|
||||
.request()
|
||||
.header(HttpHeaders.USER_AGENT, "FIXME")
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader(
|
||||
List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(
|
||||
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));
|
||||
|
||||
assertThat("Unexpected response", response.getStatus(), is(equalTo(200)));
|
||||
|
@ -1389,8 +1389,8 @@ class MessageControllerTest {
|
|||
.queryParam("urgent", false)
|
||||
.request()
|
||||
.header(HttpHeaders.USER_AGENT, "FIXME")
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader(
|
||||
List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(
|
||||
serverSecretParams, List.of(MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
|
||||
.put(Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE));
|
||||
|
||||
assertThat("Unexpected response", response.getStatus(), is(equalTo(401)));
|
||||
|
@ -1419,39 +1419,14 @@ class MessageControllerTest {
|
|||
.queryParam("urgent", false)
|
||||
.request()
|
||||
.header(HttpHeaders.USER_AGENT, "FIXME")
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN, validGroupSendTokenHeader(
|
||||
List.of(SINGLE_DEVICE_ACI_ID, MULTI_DEVICE_ACI_ID), Instant.parse("2024-04-10T00:00:00.00Z")))
|
||||
.header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(
|
||||
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));
|
||||
|
||||
assertThat("Unexpected response", response.getStatus(), is(equalTo(401)));
|
||||
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
|
||||
@ValueSource(booleans = {true, false})
|
||||
void testMultiRecipientRedisBombProtection(final boolean useExplicitIdentifier) throws Exception {
|
||||
|
|
|
@ -6,23 +6,27 @@
|
|||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
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.anyByte;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
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.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;
|
||||
import org.whispersystems.textsecuregcm.entities.ECPreKey;
|
||||
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.Device;
|
||||
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.util.TestClock;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
@ -60,6 +68,9 @@ import reactor.core.publisher.Flux;
|
|||
|
||||
class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcService, KeysAnonymousGrpc.KeysAnonymousBlockingStub> {
|
||||
|
||||
private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();
|
||||
private static final TestClock CLOCK = TestClock.now();
|
||||
|
||||
@Mock
|
||||
private AccountsManager accountsManager;
|
||||
|
||||
|
@ -68,71 +79,54 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
|
|||
|
||||
@Override
|
||||
protected KeysAnonymousGrpcService createServiceBeforeEachTest() {
|
||||
return new KeysAnonymousGrpcService(accountsManager, keysManager);
|
||||
return new KeysAnonymousGrpcService(accountsManager, keysManager, SERVER_SECRET_PARAMS, CLOCK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPreKeys() {
|
||||
void getPreKeysUnidentifiedAccessKey() {
|
||||
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 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);
|
||||
|
||||
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.getIdentifier(IdentityType.ACI)).thenReturn(identifier);
|
||||
when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);
|
||||
when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey);
|
||||
when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(identifier)))
|
||||
when(accountsManager.getByServiceIdentifierAsync(identifier))
|
||||
.thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount)));
|
||||
|
||||
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(identifier, Device.PRIMARY_ID))
|
||||
when(keysManager.takeEC(uuid, Device.PRIMARY_ID))
|
||||
.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)));
|
||||
|
||||
when(keysManager.getEcSignedPreKey(identifier, Device.PRIMARY_ID))
|
||||
when(keysManager.getEcSignedPreKey(uuid, Device.PRIMARY_ID))
|
||||
.thenReturn(CompletableFuture.completedFuture(Optional.of(ecSignedPreKey)));
|
||||
|
||||
final GetPreKeysResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()
|
||||
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
|
||||
.setRequest(GetPreKeysRequest.newBuilder()
|
||||
.setTargetIdentifier(ServiceIdentifier.newBuilder()
|
||||
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)
|
||||
.setUuid(UUIDUtil.toByteString(identifier))
|
||||
.build())
|
||||
.setDeviceId(Device.PRIMARY_ID)
|
||||
.build())
|
||||
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))
|
||||
.setDeviceId(Device.PRIMARY_ID))
|
||||
.build());
|
||||
|
||||
final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder()
|
||||
.setIdentityKey(ByteString.copyFrom(identityKey.serialize()))
|
||||
.putPreKeys(Device.PRIMARY_ID, GetPreKeysResponse.PreKeyBundle.newBuilder()
|
||||
.setEcOneTimePreKey(EcPreKey.newBuilder()
|
||||
.setKeyId(ecPreKey.keyId())
|
||||
.setPublicKey(ByteString.copyFrom(ecPreKey.serializedPublicKey()))
|
||||
.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())
|
||||
.setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey))
|
||||
.setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey))
|
||||
.setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
|
@ -140,50 +134,177 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
|
|||
}
|
||||
|
||||
@Test
|
||||
void getPreKeysIncorrectUnidentifiedAccessKey() {
|
||||
void getPreKeysGroupSendEndorsement() throws Exception {
|
||||
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 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);
|
||||
|
||||
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(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(identifier)))
|
||||
when(accountsManager.getByServiceIdentifierAsync(identifier))
|
||||
.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()
|
||||
.setTargetIdentifier(ServiceIdentifier.newBuilder()
|
||||
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)
|
||||
.setUuid(UUIDUtil.toByteString(identifier))
|
||||
.build())
|
||||
.setDeviceId(Device.PRIMARY_ID)
|
||||
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))
|
||||
.setDeviceId(Device.PRIMARY_ID))
|
||||
.build());
|
||||
|
||||
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();
|
||||
|
||||
assertEquals(expectedResponse, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPreKeysAccountNotFound() {
|
||||
when(accountsManager.getByServiceIdentifierAsync(any()))
|
||||
void getPreKeysNoAuth() {
|
||||
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()));
|
||||
|
||||
final StatusRuntimeException exception =
|
||||
assertThrows(StatusRuntimeException.class,
|
||||
() -> unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()
|
||||
.setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID()))
|
||||
.setRequest(GetPreKeysRequest.newBuilder()
|
||||
.setTargetIdentifier(ServiceIdentifier.newBuilder()
|
||||
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)
|
||||
.setUuid(UUIDUtil.toByteString(UUID.randomUUID()))
|
||||
.build())
|
||||
.build())
|
||||
.build()));
|
||||
assertGetKeysFailure(Status.UNAUTHENTICATED,
|
||||
GetPreKeysAnonymousRequest.newBuilder()
|
||||
.setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID()))
|
||||
.setRequest(GetPreKeysRequest.newBuilder()
|
||||
.setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci)))
|
||||
.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
|
||||
|
@ -203,16 +324,14 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
|
|||
when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(accountIdentifier)))
|
||||
.thenReturn(CompletableFuture.completedFuture(Optional.of(targetAccount)));
|
||||
|
||||
assertStatusException(Status.NOT_FOUND, () -> unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()
|
||||
assertGetKeysFailure(Status.NOT_FOUND, GetPreKeysAnonymousRequest.newBuilder()
|
||||
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))
|
||||
.setRequest(GetPreKeysRequest.newBuilder()
|
||||
.setTargetIdentifier(ServiceIdentifier.newBuilder()
|
||||
.setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)
|
||||
.setUuid(UUIDUtil.toByteString(accountIdentifier))
|
||||
.build())
|
||||
.setDeviceId(deviceId)
|
||||
.build())
|
||||
.build()));
|
||||
.setUuid(UUIDUtil.toByteString(accountIdentifier)))
|
||||
.setDeviceId(deviceId))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -291,4 +410,32 @@ class KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcS
|
|||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ import io.dropwizard.auth.PolymorphicAuthDynamicFeature;
|
|||
import io.dropwizard.auth.basic.BasicCredentialAuthFilter;
|
||||
import io.dropwizard.auth.basic.BasicCredentials;
|
||||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
|
@ -26,17 +28,31 @@ import java.util.List;
|
|||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.extension.AfterEachCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
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.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.AuthenticatedAccount;
|
||||
import org.whispersystems.textsecuregcm.auth.SaltedTokenHash;
|
||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.IdentityType;
|
||||
import org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.identity.ServiceIdentifier;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
@ -317,4 +333,33 @@ public class AuthHelper {
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue