Update KT search requests to include a value and maybe an unidentified access key
This commit is contained in:
parent
3fdb691702
commit
013e45596e
|
@ -56,8 +56,10 @@ public class KeyTransparencyController {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(KeyTransparencyController.class);
|
private static final Logger LOGGER = LoggerFactory.getLogger(KeyTransparencyController.class);
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final Duration KEY_TRANSPARENCY_RPC_TIMEOUT = Duration.ofSeconds(15);
|
static final Duration KEY_TRANSPARENCY_RPC_TIMEOUT = Duration.ofSeconds(15);
|
||||||
private static final byte USERNAME_PREFIX = (byte) 'u';
|
@VisibleForTesting
|
||||||
private static final byte E164_PREFIX = (byte) 'n';
|
static final byte USERNAME_PREFIX = (byte) 'u';
|
||||||
|
@VisibleForTesting
|
||||||
|
static final byte E164_PREFIX = (byte) 'n';
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final byte ACI_PREFIX = (byte) 'a';
|
static final byte ACI_PREFIX = (byte) 'a';
|
||||||
private final KeyTransparencyServiceClient keyTransparencyServiceClient;
|
private final KeyTransparencyServiceClient keyTransparencyServiceClient;
|
||||||
|
@ -76,7 +78,7 @@ public class KeyTransparencyController {
|
||||||
@ApiResponse(responseCode = "200", description = "All search key lookups were successful", useReturnTypeSchema = true)
|
@ApiResponse(responseCode = "200", description = "All search key lookups were successful", useReturnTypeSchema = true)
|
||||||
@ApiResponse(responseCode = "403", description = "At least one search key lookup to value mapping was invalid")
|
@ApiResponse(responseCode = "403", description = "At least one search key lookup to value mapping was invalid")
|
||||||
@ApiResponse(responseCode = "404", description = "At least one search key lookup did not find the key")
|
@ApiResponse(responseCode = "404", description = "At least one search key lookup did not find the key")
|
||||||
@ApiResponse(responseCode = "413", description = "Ratelimited")
|
@ApiResponse(responseCode = "429", description = "Ratelimited")
|
||||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||||
@POST
|
@POST
|
||||||
@Path("/search")
|
@Path("/search")
|
||||||
|
@ -92,6 +94,8 @@ public class KeyTransparencyController {
|
||||||
try {
|
try {
|
||||||
final CompletableFuture<byte[]> aciSearchKeyResponseFuture = keyTransparencyServiceClient.search(
|
final CompletableFuture<byte[]> aciSearchKeyResponseFuture = keyTransparencyServiceClient.search(
|
||||||
getFullSearchKeyByteString(ACI_PREFIX, request.aci().toCompactByteArray()),
|
getFullSearchKeyByteString(ACI_PREFIX, request.aci().toCompactByteArray()),
|
||||||
|
ByteString.copyFrom(request.aciIdentityKey().serialize()),
|
||||||
|
Optional.empty(),
|
||||||
request.lastTreeHeadSize(),
|
request.lastTreeHeadSize(),
|
||||||
request.distinguishedTreeHeadSize(),
|
request.distinguishedTreeHeadSize(),
|
||||||
KEY_TRANSPARENCY_RPC_TIMEOUT);
|
KEY_TRANSPARENCY_RPC_TIMEOUT);
|
||||||
|
@ -99,6 +103,8 @@ public class KeyTransparencyController {
|
||||||
final CompletableFuture<byte[]> e164SearchKeyResponseFuture = request.e164()
|
final CompletableFuture<byte[]> e164SearchKeyResponseFuture = request.e164()
|
||||||
.map(e164 -> keyTransparencyServiceClient.search(
|
.map(e164 -> keyTransparencyServiceClient.search(
|
||||||
getFullSearchKeyByteString(E164_PREFIX, e164.getBytes(StandardCharsets.UTF_8)),
|
getFullSearchKeyByteString(E164_PREFIX, e164.getBytes(StandardCharsets.UTF_8)),
|
||||||
|
ByteString.copyFrom(request.aci().toCompactByteArray()),
|
||||||
|
Optional.of(ByteString.copyFrom(request.unidentifiedAccessKey().get())),
|
||||||
request.lastTreeHeadSize(),
|
request.lastTreeHeadSize(),
|
||||||
request.distinguishedTreeHeadSize(),
|
request.distinguishedTreeHeadSize(),
|
||||||
KEY_TRANSPARENCY_RPC_TIMEOUT))
|
KEY_TRANSPARENCY_RPC_TIMEOUT))
|
||||||
|
@ -107,6 +113,8 @@ public class KeyTransparencyController {
|
||||||
final CompletableFuture<byte[]> usernameHashSearchKeyResponseFuture = request.usernameHash()
|
final CompletableFuture<byte[]> usernameHashSearchKeyResponseFuture = request.usernameHash()
|
||||||
.map(usernameHash -> keyTransparencyServiceClient.search(
|
.map(usernameHash -> keyTransparencyServiceClient.search(
|
||||||
getFullSearchKeyByteString(USERNAME_PREFIX, request.usernameHash().get()),
|
getFullSearchKeyByteString(USERNAME_PREFIX, request.usernameHash().get()),
|
||||||
|
ByteString.copyFrom(request.aci().toCompactByteArray()),
|
||||||
|
Optional.empty(),
|
||||||
request.lastTreeHeadSize(),
|
request.lastTreeHeadSize(),
|
||||||
request.distinguishedTreeHeadSize(),
|
request.distinguishedTreeHeadSize(),
|
||||||
KEY_TRANSPARENCY_RPC_TIMEOUT))
|
KEY_TRANSPARENCY_RPC_TIMEOUT))
|
||||||
|
@ -138,7 +146,7 @@ public class KeyTransparencyController {
|
||||||
)
|
)
|
||||||
@ApiResponse(responseCode = "200", description = "All search keys exist in the log", useReturnTypeSchema = true)
|
@ApiResponse(responseCode = "200", description = "All search keys exist in the log", useReturnTypeSchema = true)
|
||||||
@ApiResponse(responseCode = "404", description = "At least one search key lookup did not find the key")
|
@ApiResponse(responseCode = "404", description = "At least one search key lookup did not find the key")
|
||||||
@ApiResponse(responseCode = "413", description = "Ratelimited")
|
@ApiResponse(responseCode = "429", description = "Ratelimited")
|
||||||
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
@ApiResponse(responseCode = "422", description = "Invalid request format")
|
||||||
@POST
|
@POST
|
||||||
@Path("/monitor")
|
@Path("/monitor")
|
||||||
|
|
|
@ -8,11 +8,15 @@ package org.whispersystems.textsecuregcm.entities;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.signal.libsignal.protocol.IdentityKey;
|
||||||
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;
|
||||||
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;
|
||||||
|
import org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;
|
||||||
import org.whispersystems.textsecuregcm.util.E164;
|
import org.whispersystems.textsecuregcm.util.E164;
|
||||||
|
import org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;
|
||||||
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
|
import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;
|
||||||
|
|
||||||
|
import javax.validation.constraints.AssertTrue;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.validation.constraints.Positive;
|
import javax.validation.constraints.Positive;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
@ -33,9 +37,25 @@ public record KeyTransparencySearchRequest(
|
||||||
@Schema(description = "The username hash to look up, encoded in web-safe unpadded base64.")
|
@Schema(description = "The username hash to look up, encoded in web-safe unpadded base64.")
|
||||||
Optional<byte[]> usernameHash,
|
Optional<byte[]> usernameHash,
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@JsonSerialize(using = IdentityKeyAdapter.Serializer.class)
|
||||||
|
@JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)
|
||||||
|
@Schema(description="The public aci identity key associated with the provided aci")
|
||||||
|
IdentityKey aciIdentityKey,
|
||||||
|
|
||||||
|
@JsonSerialize(contentUsing = ByteArrayBase64WithPaddingAdapter.Serializing.class)
|
||||||
|
@JsonDeserialize(contentUsing = ByteArrayBase64WithPaddingAdapter.Deserializing.class)
|
||||||
|
@Schema(description="The unidentified access key associated with the account")
|
||||||
|
Optional<byte[]> unidentifiedAccessKey,
|
||||||
|
|
||||||
@Schema(description = "The non-distinguished tree head size to prove consistency against.")
|
@Schema(description = "The non-distinguished tree head size to prove consistency against.")
|
||||||
Optional<@Positive Long> lastTreeHeadSize,
|
Optional<@Positive Long> lastTreeHeadSize,
|
||||||
|
|
||||||
@Schema(description = "The distinguished tree head size to prove consistency against.")
|
@Schema(description = "The distinguished tree head size to prove consistency against.")
|
||||||
Optional<@Positive Long> distinguishedTreeHeadSize
|
Optional<@Positive Long> distinguishedTreeHeadSize
|
||||||
) {}
|
) {
|
||||||
|
@AssertTrue
|
||||||
|
public boolean isUnidentifiedAccessKeyProvidedWithE164() {
|
||||||
|
return unidentifiedAccessKey.isPresent() == e164.isPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -52,13 +52,19 @@ public class KeyTransparencyServiceClient implements Managed {
|
||||||
this.callbackExecutor = callbackExecutor;
|
this.callbackExecutor = callbackExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||||
public CompletableFuture<byte[]> search(
|
public CompletableFuture<byte[]> search(
|
||||||
final ByteString searchKey,
|
final ByteString searchKey,
|
||||||
|
final ByteString mappedValue,
|
||||||
|
final Optional<ByteString> unidentifiedAccessKey,
|
||||||
final Optional<Long> lastTreeHeadSize,
|
final Optional<Long> lastTreeHeadSize,
|
||||||
final Optional<Long> distinguishedTreeHeadSize,
|
final Optional<Long> distinguishedTreeHeadSize,
|
||||||
final Duration timeout) {
|
final Duration timeout) {
|
||||||
final SearchRequest.Builder searchRequestBuilder = SearchRequest.newBuilder()
|
final SearchRequest.Builder searchRequestBuilder = SearchRequest.newBuilder()
|
||||||
.setSearchKey(searchKey);
|
.setSearchKey(searchKey)
|
||||||
|
.setMappedValue(mappedValue);
|
||||||
|
|
||||||
|
unidentifiedAccessKey.ifPresent(searchRequestBuilder::setUnidentifiedAccessKey);
|
||||||
|
|
||||||
final ConsistencyParameters.Builder consistency = ConsistencyParameters.newBuilder();
|
final ConsistencyParameters.Builder consistency = ConsistencyParameters.newBuilder();
|
||||||
lastTreeHeadSize.ifPresent(consistency::setLast);
|
lastTreeHeadSize.ifPresent(consistency::setLast);
|
||||||
|
@ -71,6 +77,7 @@ public class KeyTransparencyServiceClient implements Managed {
|
||||||
.thenApply(AbstractMessageLite::toByteArray);
|
.thenApply(AbstractMessageLite::toByteArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||||
public CompletableFuture<byte[]> monitor(final List<MonitorKey> monitorKeys,
|
public CompletableFuture<byte[]> monitor(final List<MonitorKey> monitorKeys,
|
||||||
final Optional<Long> lastTreeHeadSize,
|
final Optional<Long> lastTreeHeadSize,
|
||||||
final Optional<Long> distinguishedTreeHeadSize,
|
final Optional<Long> distinguishedTreeHeadSize,
|
||||||
|
|
|
@ -48,8 +48,6 @@ message ConsistencyParameters {
|
||||||
optional uint64 distinguished = 2;
|
optional uint64 distinguished = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: add a `value` field so that the KT server can verify that the given search key is mapped
|
|
||||||
// to the provided value.
|
|
||||||
message SearchRequest {
|
message SearchRequest {
|
||||||
/**
|
/**
|
||||||
* The key to look up in the log tree.
|
* The key to look up in the log tree.
|
||||||
|
@ -67,6 +65,16 @@ message SearchRequest {
|
||||||
* The tree head size(s) to prove consistency against.
|
* The tree head size(s) to prove consistency against.
|
||||||
*/
|
*/
|
||||||
ConsistencyParameters consistency = 3;
|
ConsistencyParameters consistency = 3;
|
||||||
|
/**
|
||||||
|
* Clients need to prove that they know the search key to value mapping
|
||||||
|
* to avoid the key transparency service leaking user data.
|
||||||
|
* If the client is looking up the distinguished key, this field may be empty.
|
||||||
|
*/
|
||||||
|
optional bytes mapped_value = 4;
|
||||||
|
/**
|
||||||
|
* Clients should only provide the unidentified_access_key if the search key is a phone number.
|
||||||
|
*/
|
||||||
|
optional bytes unidentified_access_key = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SearchResponse {
|
message SearchResponse {
|
||||||
|
|
|
@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.google.common.net.HttpHeaders;
|
import com.google.common.net.HttpHeaders;
|
||||||
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
import io.dropwizard.auth.AuthValueFactoryProvider;
|
import io.dropwizard.auth.AuthValueFactoryProvider;
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
|
@ -21,6 +22,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.Arguments;
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
import org.junit.jupiter.params.provider.MethodSource;
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.signal.libsignal.protocol.IdentityKey;
|
||||||
|
import org.signal.libsignal.protocol.ecc.Curve;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||||
import org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorRequest;
|
import org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorRequest;
|
||||||
import org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorResponse;
|
import org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorResponse;
|
||||||
|
@ -41,10 +45,13 @@ import javax.ws.rs.client.Entity;
|
||||||
import javax.ws.rs.client.Invocation;
|
import javax.ws.rs.client.Invocation;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
import java.io.UncheckedIOException;
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
|
@ -62,6 +69,10 @@ import static org.mockito.Mockito.reset;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.whispersystems.textsecuregcm.controllers.KeyTransparencyController.ACI_PREFIX;
|
||||||
|
import static org.whispersystems.textsecuregcm.controllers.KeyTransparencyController.E164_PREFIX;
|
||||||
|
import static org.whispersystems.textsecuregcm.controllers.KeyTransparencyController.USERNAME_PREFIX;
|
||||||
|
import static org.whispersystems.textsecuregcm.controllers.KeyTransparencyController.getFullSearchKeyByteString;
|
||||||
|
|
||||||
@ExtendWith(DropwizardExtensionsSupport.class)
|
@ExtendWith(DropwizardExtensionsSupport.class)
|
||||||
public class KeyTransparencyControllerTest {
|
public class KeyTransparencyControllerTest {
|
||||||
|
@ -70,8 +81,11 @@ public class KeyTransparencyControllerTest {
|
||||||
PhoneNumberUtil.getInstance().getExampleNumber("US"),
|
PhoneNumberUtil.getInstance().getExampleNumber("US"),
|
||||||
PhoneNumberUtil.PhoneNumberFormat.E164);
|
PhoneNumberUtil.PhoneNumberFormat.E164);
|
||||||
private static final AciServiceIdentifier ACI = new AciServiceIdentifier(UUID.randomUUID());
|
private static final AciServiceIdentifier ACI = new AciServiceIdentifier(UUID.randomUUID());
|
||||||
|
private static final byte[] USERNAME_HASH = TestRandomUtil.nextBytes(20);
|
||||||
private static final TestRemoteAddressFilterProvider TEST_REMOTE_ADDRESS_FILTER_PROVIDER
|
private static final TestRemoteAddressFilterProvider TEST_REMOTE_ADDRESS_FILTER_PROVIDER
|
||||||
= new TestRemoteAddressFilterProvider("127.0.0.1");
|
= new TestRemoteAddressFilterProvider("127.0.0.1");
|
||||||
|
private static final IdentityKey ACI_IDENTITY_KEY = new IdentityKey(Curve.generateKeyPair().getPublicKey());
|
||||||
|
private static final byte[] UNIDENTIFIED_ACCESS_KEY = new byte[16];
|
||||||
private final KeyTransparencyServiceClient keyTransparencyServiceClient = mock(KeyTransparencyServiceClient.class);
|
private final KeyTransparencyServiceClient keyTransparencyServiceClient = mock(KeyTransparencyServiceClient.class);
|
||||||
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
|
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
|
||||||
private static final RateLimiter searchRatelimiter = mock(RateLimiter.class);
|
private static final RateLimiter searchRatelimiter = mock(RateLimiter.class);
|
||||||
|
@ -103,29 +117,34 @@ public class KeyTransparencyControllerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getFullSearchKey() {
|
void getFullSearchKey() {
|
||||||
final byte[] charBytes = new byte[]{KeyTransparencyController.ACI_PREFIX};
|
final byte[] charBytes = new byte[]{ACI_PREFIX};
|
||||||
final byte[] aci = ACI.toCompactByteArray();
|
final byte[] aci = ACI.toCompactByteArray();
|
||||||
|
|
||||||
final byte[] expectedFullSearchKey = new byte[aci.length + 1];
|
final byte[] expectedFullSearchKey = new byte[aci.length + 1];
|
||||||
System.arraycopy(charBytes, 0, expectedFullSearchKey, 0, charBytes.length);
|
System.arraycopy(charBytes, 0, expectedFullSearchKey, 0, charBytes.length);
|
||||||
System.arraycopy(aci, 0, expectedFullSearchKey, charBytes.length, aci.length);
|
System.arraycopy(aci, 0, expectedFullSearchKey, charBytes.length, aci.length);
|
||||||
|
|
||||||
assertArrayEquals(expectedFullSearchKey,
|
assertArrayEquals(expectedFullSearchKey, getFullSearchKeyByteString(KeyTransparencyController.ACI_PREFIX, aci).toByteArray());
|
||||||
KeyTransparencyController.getFullSearchKeyByteString(KeyTransparencyController.ACI_PREFIX, aci).toByteArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void searchSuccess(final Optional<String> e164, final Optional<byte[]> usernameHash, final int expectedNumClientCalls) {
|
void searchSuccess(final Optional<String> e164, final Optional<byte[]> usernameHash, final int expectedNumClientCalls,
|
||||||
when(keyTransparencyServiceClient.search(any(), any(), any(), any()))
|
final Set<ByteString> expectedSearchKeys,
|
||||||
|
final Set<ByteString> expectedValues,
|
||||||
|
final List<Optional<ByteString>> expectedUnidentifiedAccessKey) {
|
||||||
|
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(CompletableFuture.completedFuture(TestRandomUtil.nextBytes(16)));
|
.thenReturn(CompletableFuture.completedFuture(TestRandomUtil.nextBytes(16)));
|
||||||
|
|
||||||
final Invocation.Builder request = resources.getJerseyTest()
|
final Invocation.Builder request = resources.getJerseyTest()
|
||||||
.target("/v1/key-transparency/search")
|
.target("/v1/key-transparency/search")
|
||||||
.request();
|
.request();
|
||||||
|
|
||||||
final String searchJson = createSearchRequestJson(ACI, e164, usernameHash, Optional.of(3L), Optional.of(4L));
|
final Optional<byte[]> unidentifiedAccessKey = e164.isPresent() ? Optional.of(UNIDENTIFIED_ACCESS_KEY) : Optional.empty();
|
||||||
|
final String searchJson = createSearchRequestJson(ACI, e164, usernameHash, ACI_IDENTITY_KEY,
|
||||||
|
unidentifiedAccessKey, Optional.of(3L), Optional.of(4L));
|
||||||
|
|
||||||
try (Response response = request.post(Entity.json(searchJson))) {
|
try (Response response = request.post(Entity.json(searchJson))) {
|
||||||
assertEquals(200, response.getStatus());
|
assertEquals(200, response.getStatus());
|
||||||
|
|
||||||
|
@ -140,16 +159,45 @@ public class KeyTransparencyControllerTest {
|
||||||
e164.ifPresentOrElse(ignored -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isPresent()),
|
e164.ifPresentOrElse(ignored -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isPresent()),
|
||||||
() -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isEmpty()));
|
() -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isEmpty()));
|
||||||
|
|
||||||
verify(keyTransparencyServiceClient, times(expectedNumClientCalls)).search(any(), eq(Optional.of(3L)), eq(Optional.of(4L)),
|
ArgumentCaptor<ByteString> valueArguments = ArgumentCaptor.forClass(ByteString.class);
|
||||||
|
ArgumentCaptor<ByteString> searchKeyArguments = ArgumentCaptor.forClass(ByteString.class);
|
||||||
|
ArgumentCaptor<Optional<ByteString>> unidentifiedAccessKeyArgument = ArgumentCaptor.forClass(Optional.class);
|
||||||
|
|
||||||
|
verify(keyTransparencyServiceClient, times(expectedNumClientCalls)).search(searchKeyArguments.capture(), valueArguments.capture(), unidentifiedAccessKeyArgument.capture(), eq(Optional.of(3L)), eq(Optional.of(4L)),
|
||||||
eq(KeyTransparencyController.KEY_TRANSPARENCY_RPC_TIMEOUT));
|
eq(KeyTransparencyController.KEY_TRANSPARENCY_RPC_TIMEOUT));
|
||||||
|
|
||||||
|
assertEquals(expectedSearchKeys, new HashSet<>(searchKeyArguments.getAllValues()));
|
||||||
|
assertEquals(expectedValues, new HashSet<>(valueArguments.getAllValues()));
|
||||||
|
assertEquals(expectedUnidentifiedAccessKey, unidentifiedAccessKeyArgument.getAllValues());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> searchSuccess() {
|
private static Stream<Arguments> searchSuccess() {
|
||||||
|
final byte[] aciBytes = ACI.toCompactByteArray();
|
||||||
|
final ByteString aciValueByteString = ByteString.copyFrom(aciBytes);
|
||||||
|
|
||||||
|
final byte[] aciIdentityKeyBytes = ACI_IDENTITY_KEY.serialize();
|
||||||
|
final ByteString aciIdentityKeyValueByteString = ByteString.copyFrom(aciIdentityKeyBytes);
|
||||||
|
|
||||||
|
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
Arguments.of(Optional.empty(), Optional.empty(), 1),
|
// Only looking up ACI; ACI identity key should be the only value provided; no UAK
|
||||||
Arguments.of(Optional.empty(), Optional.of(TestRandomUtil.nextBytes(20)), 2),
|
Arguments.of(Optional.empty(), Optional.empty(), 1,
|
||||||
Arguments.of(Optional.of(NUMBER), Optional.empty(), 2)
|
Set.of(getFullSearchKeyByteString(ACI_PREFIX, aciBytes)),
|
||||||
|
Set.of(aciIdentityKeyValueByteString),
|
||||||
|
List.of(Optional.empty())),
|
||||||
|
// Looking up ACI and username hash; ACI identity key and ACI should be the values provided; no UAK
|
||||||
|
Arguments.of(Optional.empty(), Optional.of(USERNAME_HASH), 2,
|
||||||
|
Set.of(getFullSearchKeyByteString(ACI_PREFIX, aciBytes),
|
||||||
|
getFullSearchKeyByteString(USERNAME_PREFIX, USERNAME_HASH)),
|
||||||
|
Set.of(aciIdentityKeyValueByteString, aciValueByteString),
|
||||||
|
List.of(Optional.empty(), Optional.empty())),
|
||||||
|
// Looking up ACI and phone number; ACI identity key and ACI should be the values provided; must provide UAK
|
||||||
|
Arguments.of(Optional.of(NUMBER), Optional.empty(), 2,
|
||||||
|
Set.of(getFullSearchKeyByteString(ACI_PREFIX, aciBytes),
|
||||||
|
getFullSearchKeyByteString(E164_PREFIX, NUMBER.getBytes(StandardCharsets.UTF_8))),
|
||||||
|
Set.of(aciValueByteString, aciIdentityKeyValueByteString),
|
||||||
|
List.of(Optional.empty(), Optional.of(ByteString.copyFrom(UNIDENTIFIED_ACCESS_KEY))))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,24 +207,26 @@ public class KeyTransparencyControllerTest {
|
||||||
.target("/v1/key-transparency/search")
|
.target("/v1/key-transparency/search")
|
||||||
.request()
|
.request()
|
||||||
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));
|
.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));
|
||||||
try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())))) {
|
try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty(),
|
||||||
|
ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.empty())))) {
|
||||||
assertEquals(400, response.getStatus());
|
assertEquals(400, response.getStatus());
|
||||||
}
|
}
|
||||||
verify(keyTransparencyServiceClient, never()).search(any(), any(), any(), any());
|
verify(keyTransparencyServiceClient, never()).search(any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void searchGrpcErrors(final Status grpcStatus, final int httpStatus) {
|
void searchGrpcErrors(final Status grpcStatus, final int httpStatus) {
|
||||||
when(keyTransparencyServiceClient.search(any(), any(), any(), any()))
|
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(CompletableFuture.failedFuture(new CompletionException(new StatusRuntimeException(grpcStatus))));
|
.thenReturn(CompletableFuture.failedFuture(new CompletionException(new StatusRuntimeException(grpcStatus))));
|
||||||
|
|
||||||
final Invocation.Builder request = resources.getJerseyTest()
|
final Invocation.Builder request = resources.getJerseyTest()
|
||||||
.target("/v1/key-transparency/search")
|
.target("/v1/key-transparency/search")
|
||||||
.request();
|
.request();
|
||||||
try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())))) {
|
try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty(),
|
||||||
|
ACI_IDENTITY_KEY, Optional.empty(),Optional.empty(), Optional.empty())))) {
|
||||||
assertEquals(httpStatus, response.getStatus());
|
assertEquals(httpStatus, response.getStatus());
|
||||||
verify(keyTransparencyServiceClient, times(1)).search(any(), any(), any(), any());
|
verify(keyTransparencyServiceClient, times(1)).search(any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,27 +243,37 @@ public class KeyTransparencyControllerTest {
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource
|
@MethodSource
|
||||||
void searchInvalidRequest(final AciServiceIdentifier aci,
|
void searchInvalidRequest(final AciServiceIdentifier aci,
|
||||||
|
final IdentityKey aciIdentityKey,
|
||||||
|
final Optional<String> e164,
|
||||||
|
final Optional<byte[]> unidentifiedAccessKey,
|
||||||
final Optional<Long> lastTreeHeadSize,
|
final Optional<Long> lastTreeHeadSize,
|
||||||
final Optional<Long> distinguishedTreeHeadSize) {
|
final Optional<Long> distinguishedTreeHeadSize) {
|
||||||
final Invocation.Builder request = resources.getJerseyTest()
|
final Invocation.Builder request = resources.getJerseyTest()
|
||||||
.target("/v1/key-transparency/search")
|
.target("/v1/key-transparency/search")
|
||||||
.request();
|
.request();
|
||||||
try (Response response = request.post(Entity.json(
|
try (Response response = request.post(Entity.json(
|
||||||
createSearchRequestJson(aci, Optional.empty(), Optional.empty(), lastTreeHeadSize, distinguishedTreeHeadSize)))) {
|
createSearchRequestJson(aci, e164, Optional.empty(),
|
||||||
|
aciIdentityKey, unidentifiedAccessKey, lastTreeHeadSize, distinguishedTreeHeadSize)))) {
|
||||||
assertEquals(422, response.getStatus());
|
assertEquals(422, response.getStatus());
|
||||||
verify(keyTransparencyServiceClient, never()).search(any(), any(), any(), any());
|
verify(keyTransparencyServiceClient, never()).search(any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Stream<Arguments> searchInvalidRequest() {
|
private static Stream<Arguments> searchInvalidRequest() {
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
// ACI can't be null
|
// ACI can't be null
|
||||||
Arguments.of(null, Optional.empty(), Optional.empty()),
|
Arguments.of(null, ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),
|
||||||
|
// ACI identity key can't be null
|
||||||
|
Arguments.of(ACI, null, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),
|
||||||
// lastNonDistinguishedTreeHeadSize must be positive
|
// lastNonDistinguishedTreeHeadSize must be positive
|
||||||
Arguments.of(ACI, Optional.of(0L), Optional.empty()),
|
Arguments.of(ACI, ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.of(0L), Optional.empty()),
|
||||||
// lastDistinguishedTreeHeadSize must be positive
|
// lastDistinguishedTreeHeadSize must be positive
|
||||||
Arguments.of(ACI, Optional.empty(), Optional.of(0L))
|
Arguments.of(ACI, ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(0L)),
|
||||||
);
|
// E164 can't be provided without an unidentified access key
|
||||||
|
Arguments.of(ACI, ACI_IDENTITY_KEY, Optional.of(NUMBER), Optional.empty(), Optional.empty(), Optional.empty()),
|
||||||
|
// ...and an unidentified access key can't be provided without an E164
|
||||||
|
Arguments.of(ACI, ACI_IDENTITY_KEY, Optional.empty(), Optional.of(UNIDENTIFIED_ACCESS_KEY), Optional.empty(), Optional.empty())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -223,9 +283,10 @@ public class KeyTransparencyControllerTest {
|
||||||
final Invocation.Builder request = resources.getJerseyTest()
|
final Invocation.Builder request = resources.getJerseyTest()
|
||||||
.target("/v1/key-transparency/search")
|
.target("/v1/key-transparency/search")
|
||||||
.request();
|
.request();
|
||||||
try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())))) {
|
try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty(),
|
||||||
|
ACI_IDENTITY_KEY, Optional.empty(),Optional.empty(), Optional.empty())))) {
|
||||||
assertEquals(429, response.getStatus());
|
assertEquals(429, response.getStatus());
|
||||||
verify(keyTransparencyServiceClient, never()).search(any(), any(), any(), any());
|
verify(keyTransparencyServiceClient, never()).search(any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -319,10 +380,10 @@ public class KeyTransparencyControllerTest {
|
||||||
Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.empty(), Optional.of(List.of(5L)),
|
Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.empty(), Optional.of(List.of(5L)),
|
||||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())),
|
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())),
|
||||||
// usernameHashPosition cannot be empty if usernameHash isn't
|
// usernameHashPosition cannot be empty if usernameHash isn't
|
||||||
Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.of(TestRandomUtil.nextBytes(20)),
|
Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.of(USERNAME_HASH),
|
||||||
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())),
|
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())),
|
||||||
// usernameHashPositions list cannot be empty
|
// usernameHashPositions list cannot be empty
|
||||||
Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.of(TestRandomUtil.nextBytes(20)),
|
Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.of(USERNAME_HASH),
|
||||||
Optional.of(Collections.emptyList()), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())),
|
Optional.of(Collections.emptyList()), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())),
|
||||||
// e164 cannot be empty if e164Positions isn't
|
// e164 cannot be empty if e164Positions isn't
|
||||||
Arguments.of(
|
Arguments.of(
|
||||||
|
@ -382,9 +443,11 @@ public class KeyTransparencyControllerTest {
|
||||||
final AciServiceIdentifier aci,
|
final AciServiceIdentifier aci,
|
||||||
final Optional<String> e164,
|
final Optional<String> e164,
|
||||||
final Optional<byte[]> usernameHash,
|
final Optional<byte[]> usernameHash,
|
||||||
|
final IdentityKey aciIdentityKey,
|
||||||
|
final Optional<byte[]> unidentifiedAccessKey,
|
||||||
final Optional<Long> lastTreeHeadSize,
|
final Optional<Long> lastTreeHeadSize,
|
||||||
final Optional<Long> distinguishedTreeHeadSize) {
|
final Optional<Long> distinguishedTreeHeadSize) {
|
||||||
final KeyTransparencySearchRequest request = new KeyTransparencySearchRequest(aci, e164, usernameHash, lastTreeHeadSize, distinguishedTreeHeadSize);
|
final KeyTransparencySearchRequest request = new KeyTransparencySearchRequest(aci, e164, usernameHash, aciIdentityKey, unidentifiedAccessKey, lastTreeHeadSize, distinguishedTreeHeadSize);
|
||||||
try {
|
try {
|
||||||
return SystemMapper.jsonMapper().writeValueAsString(request);
|
return SystemMapper.jsonMapper().writeValueAsString(request);
|
||||||
} catch (final JsonProcessingException e) {
|
} catch (final JsonProcessingException e) {
|
||||||
|
|
Loading…
Reference in New Issue