Update chat to send three search keys in one request to KT

This commit is contained in:
Katherine 2024-10-29 09:52:26 -04:00 committed by GitHub
parent 89292e238b
commit 712f3affd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 240 additions and 160 deletions

View File

@ -21,7 +21,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@ -38,6 +37,7 @@ import javax.ws.rs.ServerErrorException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.MonitorKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -74,17 +74,25 @@ public class KeyTransparencyController {
}
@Operation(
summary = "Search for the given search keys in the key transparency log",
summary = "Search for the given identifiers in the key transparency log",
description = """
Enforced unauthenticated endpoint. Returns a response if all search keys exist in the key transparency log.
Returns a response if the ACI exists in the transparency log and its mapped value matches the provided
ACI identity key.
The username hash search response field is populated if it is found in the log and its mapped value matches
the provided ACI. The E164 search response is populated similarly, with some additional requirements:
- The account associated with the provided ACI must be discoverable by phone number.
- The provided unidentified access key must match the one on the account.
Enforced unauthenticated endpoint.
"""
)
@ApiResponse(responseCode = "200", description = "All search key lookups were successful", useReturnTypeSchema = true)
@ApiResponse(responseCode = "200", description = "The ACI was found and its mapped value matched the provided ACI identity key", useReturnTypeSchema = true)
@ApiResponse(responseCode = "400", description = "Invalid request. See response for any available details.")
@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 = "429", description = "Rate-limited")
@ApiResponse(responseCode = "403", description = "The ACI was found but its mapped value did not match the provided ACI identity key")
@ApiResponse(responseCode = "404", description = "The ACI was not found in the log")
@ApiResponse(responseCode = "422", description = "Invalid request format")
@ApiResponse(responseCode = "429", description = "Rate-limited")
@POST
@Path("/search")
@RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP)
@ -97,41 +105,29 @@ public class KeyTransparencyController {
requireNotAuthenticated(authenticatedAccount);
try {
final CompletableFuture<byte[]> aciSearchKeyResponseFuture = keyTransparencyServiceClient.search(
getFullSearchKeyByteString(ACI_PREFIX, request.aci().toCompactByteArray()),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
Optional.empty(),
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize(),
KEY_TRANSPARENCY_RPC_TIMEOUT);
final Optional<E164SearchRequest> maybeE164SearchRequest =
request.e164().flatMap(e164 -> request.unidentifiedAccessKey().map(uak ->
E164SearchRequest.newBuilder()
.setE164(e164)
.setUnidentifiedAccessKey(ByteString.copyFrom(request.unidentifiedAccessKey().get()))
.build()
));
final CompletableFuture<byte[]> e164SearchKeyResponseFuture = request.e164()
.map(e164 -> keyTransparencyServiceClient.search(
getFullSearchKeyByteString(E164_PREFIX, e164.getBytes(StandardCharsets.UTF_8)),
return keyTransparencyServiceClient.search(
ByteString.copyFrom(request.aci().toCompactByteArray()),
Optional.of(ByteString.copyFrom(request.unidentifiedAccessKey().get())),
ByteString.copyFrom(request.aciIdentityKey().serialize()),
request.usernameHash().map(ByteString::copyFrom),
maybeE164SearchRequest,
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize(),
KEY_TRANSPARENCY_RPC_TIMEOUT))
.orElse(CompletableFuture.completedFuture(null));
final CompletableFuture<byte[]> usernameHashSearchKeyResponseFuture = request.usernameHash()
.map(usernameHash -> keyTransparencyServiceClient.search(
getFullSearchKeyByteString(USERNAME_PREFIX, request.usernameHash().get()),
ByteString.copyFrom(request.aci().toCompactByteArray()),
Optional.empty(),
request.lastTreeHeadSize(),
request.distinguishedTreeHeadSize(),
KEY_TRANSPARENCY_RPC_TIMEOUT))
.orElse(CompletableFuture.completedFuture(null));
return CompletableFuture.allOf(aciSearchKeyResponseFuture, e164SearchKeyResponseFuture,
usernameHashSearchKeyResponseFuture)
.thenApply(ignored ->
new KeyTransparencySearchResponse(aciSearchKeyResponseFuture.join(),
Optional.ofNullable(e164SearchKeyResponseFuture.join()),
Optional.ofNullable(usernameHashSearchKeyResponseFuture.join())))
.join();
KEY_TRANSPARENCY_RPC_TIMEOUT)
.thenApply(searchResponse ->
new KeyTransparencySearchResponse(
searchResponse.getTreeHead().toByteArray(),
searchResponse.getAci().toByteArray(),
searchResponse.hasE164() ? Optional.of(searchResponse.getE164().toByteArray()) : Optional.empty(),
searchResponse.hasUsernameHash() ? Optional.of(searchResponse.getUsernameHash().toByteArray()) : Optional.empty())
).join();
} catch (final CancellationException exception) {
LOGGER.error("Unexpected cancellation from key transparency service", exception);
throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, exception);
@ -143,10 +139,10 @@ public class KeyTransparencyController {
}
@Operation(
summary = "Monitor the given search keys in the key transparency log",
summary = "Monitor the given identifiers in the key transparency log",
description = """
Enforced unauthenticated endpoint. Return proofs proving that the log tree
has been constructed correctly in later entries for each of the given search keys .
Return proofs proving that the log tree has been constructed correctly in later entries for each of the given
identifiers. Enforced unauthenticated endpoint.
"""
)
@ApiResponse(responseCode = "200", description = "All search keys exist in the log", useReturnTypeSchema = true)
@ -199,8 +195,9 @@ public class KeyTransparencyController {
@Operation(
summary = "Get the current value of the distinguished key",
description = """
Enforced unauthenticated endpoint. The response contains the distinguished tree head to prove consistency
against for future calls to `/search` and `/distinguished`.
The response contains the distinguished tree head to prove consistency
against for future calls to `/search`, `/monitor`, and `/distinguished`.
Enforced unauthenticated endpoint.
"""
)
@ApiResponse(responseCode = "200", description = "The `distinguished` search key exists in the log", useReturnTypeSchema = true)

View File

@ -25,11 +25,11 @@ public record KeyTransparencySearchRequest(
@NotNull
@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)
@JsonDeserialize(using = ServiceIdentifierAdapter.AciServiceIdentifierDeserializer.class)
@Schema(description = "The aci identifier to look up")
@Schema(description = "The ACI to look up")
AciServiceIdentifier aci,
@E164
@Schema(description = "The e164-formatted phone number to look up")
@Schema(description = "The E164-formatted phone number to look up")
Optional<String> e164,
@JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class)
@ -40,7 +40,7 @@ public record KeyTransparencySearchRequest(
@NotNull
@JsonSerialize(using = IdentityKeyAdapter.Serializer.class)
@JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)
@Schema(description="The public aci identity key associated with the provided aci")
@Schema(description="The public ACI identity key associated with the provided ACI")
IdentityKey aciIdentityKey,
@JsonSerialize(contentUsing = ByteArrayBase64WithPaddingAdapter.Serializing.class)

View File

@ -17,16 +17,22 @@ public record KeyTransparencySearchResponse(
@NotNull
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@Schema(description = "The search response for the aci search key encoded in standard un-padded base64")
@Schema(description = "The `FullTreeHead` protobuf encoded in standard un-padded base64. This should be used across all identifiers.")
byte[] fullTreeHead,
@NotNull
@JsonSerialize(using = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)
@Schema(description = "The `TreeSearchResponse` protobuf for the ACI identifier encoded in standard un-padded base64")
byte[] aciSearchResponse,
@JsonSerialize(contentUsing = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(contentUsing = ByteArrayAdapter.Deserializing.class)
@Schema(description = "The search response for the e164 search key encoded in standard un-padded base64")
@Schema(description = "The `TreeSearchResponse` protobuf for the E164 encoded in standard un-padded base64")
Optional<byte[]> e164SearchResponse,
@JsonSerialize(contentUsing = ByteArrayAdapter.Serializing.class)
@JsonDeserialize(contentUsing = ByteArrayAdapter.Deserializing.class)
@Schema(description = "The search response for the username hash search key encoded in standard un-padded base64")
@Schema(description = "The `TreeSearchResponse` protobuf for the username hash encoded in standard un-padded base64")
Optional<byte[]> usernameHashSearchResponse
) {}

View File

@ -26,10 +26,12 @@ import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import org.signal.keytransparency.client.ConsistencyParameters;
import org.signal.keytransparency.client.DistinguishedRequest;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
import org.signal.keytransparency.client.MonitorKey;
import org.signal.keytransparency.client.MonitorRequest;
import org.signal.keytransparency.client.SearchRequest;
import org.signal.keytransparency.client.SearchResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
@ -111,28 +113,29 @@ public class KeyTransparencyServiceClient implements Managed {
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public CompletableFuture<byte[]> search(
final ByteString searchKey,
final ByteString mappedValue,
final Optional<ByteString> unidentifiedAccessKey,
public CompletableFuture<SearchResponse> search(
final ByteString aci,
final ByteString aciIdentityKey,
final Optional<ByteString> usernameHash,
final Optional<E164SearchRequest> e164SearchRequest,
final Optional<Long> lastTreeHeadSize,
final Optional<Long> distinguishedTreeHeadSize,
final Duration timeout) {
final SearchRequest.Builder searchRequestBuilder = SearchRequest.newBuilder()
.setSearchKey(searchKey)
.setMappedValue(mappedValue);
final SearchRequest.Builder searchKeysRequestBuilder = SearchRequest.newBuilder()
.setAci(aci)
.setAciIdentityKey(aciIdentityKey);
unidentifiedAccessKey.ifPresent(searchRequestBuilder::setUnidentifiedAccessKey);
usernameHash.ifPresent(searchKeysRequestBuilder::setUsernameHash);
e164SearchRequest.ifPresent(searchKeysRequestBuilder::setE164SearchRequest);
final ConsistencyParameters.Builder consistency = ConsistencyParameters.newBuilder();
lastTreeHeadSize.ifPresent(consistency::setLast);
distinguishedTreeHeadSize.ifPresent(consistency::setDistinguished);
searchRequestBuilder.setConsistency(consistency);
searchKeysRequestBuilder.setConsistency(consistency.build());
return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout))
.search(searchRequestBuilder.build()), callbackExecutor)
.thenApply(AbstractMessageLite::toByteArray);
.search(searchKeysRequestBuilder.build()), callbackExecutor);
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")

View File

@ -11,9 +11,15 @@ option java_package = "org.signal.keytransparency.client";
package kt_query;
/**
* An external-facing, read-only key transparency service used by Signal's chat server
* to look up and monitor search keys.
*/
* An external-facing, read-only key transparency service used by Signal's chat server
* to look up and monitor identifiers.
* There are three types of identifier mappings stored by the key transparency log:
* - An ACI which maps to an ACI identity key
* - An E164-formatted phone number which maps to an ACI
* - A username hash which also maps to an ACI
* Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.
* Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.
*/
service KeyTransparencyQueryService {
/**
* An endpoint used by clients to retrieve the most recent distinguished tree
@ -21,19 +27,86 @@ service KeyTransparencyQueryService {
* subsequent Search and Monitor requests. It should be the first key
* transparency RPC a client calls.
*/
rpc Distinguished(DistinguishedRequest) returns (SearchResponse) {}
rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}
/**
* An endpoint used by clients to search for a given key in the transparency log.
* The server returns proof that the search key exists in the log.
* An endpoint used by clients to search for one or more identifiers in the transparency log.
* The server returns proof that the identifier(s) exist in the log.
*/
rpc Search(SearchRequest) returns (SearchResponse) {}
/**
* An endpoint that allows users to monitor a set of search keys by returning proof that the log continues to be
* constructed correctly in later entries for those search keys.
* An endpoint that allows users to monitor a set of identifiers by returning proof that the log continues to be
* constructed correctly in later entries for those identifiers.
*/
rpc Monitor(MonitorRequest) returns (MonitorResponse) {}
}
message SearchRequest {
/**
* The ACI to look up in the log.
*/
bytes aci = 1;
/**
* The ACI identity key that the client thinks the ACI maps to in the log.
*/
bytes aci_identity_key = 2;
/**
* The username hash to look up in the log.
*/
optional bytes username_hash = 3;
/**
* The E164 to look up in the log along with associated data.
*/
optional E164SearchRequest e164_search_request = 4;
/**
* The tree head size(s) to prove consistency against.
*/
ConsistencyParameters consistency = 5;
}
/**
* E164SearchRequest contains the data that the user must provide when looking up an E164.
*/
message E164SearchRequest {
/**
* The E164 that the client wishes to look up in the transparency log.
*/
string e164 = 1;
/**
* The unidentified access key of the account associated with the provided E164.
*/
bytes unidentified_access_key = 2;
}
/**
* SearchResponse contains search proofs for each of the requested identifiers.
* Callers should use the top-level `FullTreeHead` for verification;
* the `FullTreeHead` field on the individual `TreeSearchResponse`s will be empty.
*/
message SearchResponse {
/**
* A signed representation of the log tree's current state along with some
* additional information necessary for validation such as a consistency proof and an auditor-signed tree head.
*/
FullTreeHead tree_head = 1;
/**
* The ACI search response is always provided.
*/
TreeSearchResponse aci = 2;
/**
* This response is only provided if all of the conditions are met:
* - the E164 exists in the log
* - its mapped ACI matches the one provided in the request
* - the account associated with the ACI is discoverable
* - the unidentified access key provided in E164SearchRequest matches the one on the account
*/
optional TreeSearchResponse e164 = 3;
/**
* This response is only provided if the username hash exists in the log and
* its mapped ACI matches the one provided in the request.
*/
optional TreeSearchResponse username_hash = 4;
}
/**
* The tree head size(s) to prove consistency against. A client's very first
* key transparency request should be looking up the "distinguished" key;
@ -43,7 +116,7 @@ service KeyTransparencyQueryService {
message ConsistencyParameters {
/**
* The non-distinguished tree head size to prove consistency against.
* This field may be omitted if the client is looking up a search key
* This field may be omitted if the client is looking up an identifier
* for the first time.
*/
optional uint64 last = 1;
@ -68,57 +141,47 @@ message DistinguishedRequest {
optional uint64 last = 1;
}
message SearchRequest {
/**
* DistinguishedResponse contains the tree head and search proof for the most
* recent `distinguished` key in the log. Callers should use the top-level
* `FullTreeHead` for verification; the `FullTreeHead` field on
* `TreeSearchResponse` will be empty.
*/
message DistinguishedResponse {
/**
* The key to look up in the log tree.
* A key can be an ACI, an E164 phone number, or a username hash and
* is prefixed with a unique identifier to indicate its type.
* A signed representation of the log tree's current state along with some
* additional information necessary for validation such as a consistency proof and an auditor-signed tree head.
*/
bytes search_key = 1;
FullTreeHead full_tree_head = 1;
/**
* TODO: Remove this protobuf field in the KT server
* The version of the key to look up. If not specified, the key transparency server
* defaults to returning the most recent version of the key.
* This search response is always provided.
*/
optional uint32 version = 2;
/**
* The tree head size(s) to prove consistency against.
*/
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;
TreeSearchResponse distinguished_response = 2;
}
message SearchResponse {
message TreeSearchResponse {
/**
* A signed representation of the log tree's current state along with some
* additional information necessary for validation such as a consistency proof and an auditor-signed tree head.
*/
FullTreeHead tree_head = 1;
/**
* A proof that is combined with the original requested search key and the VRF public key
* A proof that is combined with the original requested identifier and the VRF public key
* and outputs whether the proof is valid, and if so, the commitment index.
*/
bytes vrf_proof = 2;
/**
* A proof that the binary search for the given search key was done correctly.
* A proof that the binary search for the given identifier was done correctly.
*/
SearchProof search = 3;
/**
* A 32 byte value computed based on the log position and a random 32 byte key that is only known by
* the key transparency service. It is provided so that clients can recompute and verify the commitment.
* A 32-byte value computed based on the log position of the identifier
* and a random 32 byte key that is only known by the key transparency service.
* It is provided so that clients can recompute and verify the commitment.
*/
bytes opening = 4;
/**
* The new or updated value of the search key.
* The new or updated value that the identifier maps to.
*/
UpdateValue value = 5;
}
@ -193,17 +256,17 @@ message ProofStep {
message SearchProof {
/**
* The position in the log tree of the first occurrence of the requested search key.
* The position in the log tree of the first occurrence of the requested identifier.
*/
uint64 pos = 1;
/**
* The steps of a binary search through the entries of the log tree for the given search key version.
* The steps of a binary search through the entries of the log tree for the given identifier version.
* Each ProofStep corresponds to a log entry and provides the information necessary to recompute a log tree
* leaf hash.
*/
repeated ProofStep steps = 2;
/**
* A batch inclusion proof for all log tree leaves involved in the binary search for the given search key.
* A batch inclusion proof for all log tree leaves involved in the binary search for the given identifier.
*/
repeated bytes inclusion = 3;
}
@ -215,19 +278,19 @@ message UpdateValue {
*/
// optional bytes signature = 1;
/**
* The new value for a search key.
* The new value for a identifier.
*/
bytes value = 2;
}
message PrefixSearchResult {
/**
* A proof from a prefix tree that indicates a search was done correctly for a given search key.
* A proof from a prefix tree that indicates a search was done correctly for a given identifier.
* The elements of this array are the copath of the prefix tree leaf node in bottom-to-top order.
*/
repeated bytes proof = 1;
/**
* The version of the requested search key in the prefix tree.
* The version of the requested identifier in the prefix tree.
*/
uint32 counter = 2;
}
@ -238,7 +301,7 @@ message MonitorKey {
*/
bytes search_key = 1;
/**
* A list of log tree positions maintained by a client for the search key being monitored.
* A list of log tree positions maintained by a client for the identifier being monitored.
* Each position is in the direct path to a key version and corresponds to a tree head
* that has been verified to contain that version or greater.
* The key transparency server uses this list to compute which log entries to return
@ -246,7 +309,7 @@ message MonitorKey {
*/
repeated uint64 entries = 2;
/**
* The commitment index for the search key. This is derived from vrf_proof in
* The commitment index for the identifier. This is derived from vrf_proof in
* the SearchResponse.
*/
bytes commitment_index = 3;
@ -259,8 +322,8 @@ message MonitorRequest {
*/
repeated MonitorKey owned_keys = 1;
/**
* The list of search keys that the client would like to monitor.
* All search keys *must* belong to the same user.
* The list of identifiers that the client would like to monitor.
* All identifiers *must* belong to the same user.
*/
repeated MonitorKey contact_keys = 2;
/**
@ -292,8 +355,8 @@ message MonitorResponse {
*/
repeated MonitorProof owned_proofs = 2;
/**
* A list of proofs, one for each key in MonitorRequest.contact_keys, each proving that the given search key
* continues to be constructed correctly in later entries of hte log tree.
* A list of proofs, one for each identifier in MonitorRequest.contact_keys, each proving that the given identifier
* continues to be constructed correctly in later entries of the log tree.
*/
repeated MonitorProof contact_proofs = 3;
/**

View File

@ -12,34 +12,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
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;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.google.common.net.HttpHeaders;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
@ -59,6 +54,12 @@ import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.signal.keytransparency.client.E164SearchRequest;
import org.signal.keytransparency.client.FullTreeHead;
import org.signal.keytransparency.client.SearchProof;
import org.signal.keytransparency.client.SearchResponse;
import org.signal.keytransparency.client.TreeSearchResponse;
import org.signal.keytransparency.client.UpdateValue;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.ecc.Curve;
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
@ -137,12 +138,25 @@ public class KeyTransparencyControllerTest {
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@ParameterizedTest
@MethodSource
void searchSuccess(final Optional<String> e164, final Optional<byte[]> usernameHash, final int expectedNumClientCalls,
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)));
void searchSuccess(final Optional<String> e164, final Optional<byte[]> usernameHash) {
final TreeSearchResponse aciSearchResponse = TreeSearchResponse.newBuilder()
.setOpening(ByteString.copyFrom(TestRandomUtil.nextBytes(16)))
.setTreeHead(FullTreeHead.getDefaultInstance())
.setSearch(SearchProof.getDefaultInstance())
.setValue(UpdateValue.newBuilder()
.setValue(ByteString.copyFrom(TestRandomUtil.nextBytes(16)))
.build())
.build();
final SearchResponse.Builder searchResponseBuilder = SearchResponse.newBuilder()
.setTreeHead(FullTreeHead.getDefaultInstance())
.setAci(aciSearchResponse);
e164.ifPresent(ignored -> searchResponseBuilder.setE164(TreeSearchResponse.getDefaultInstance()));
usernameHash.ifPresent(ignored -> searchResponseBuilder.setUsernameHash(TreeSearchResponse.getDefaultInstance()));
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(CompletableFuture.completedFuture(searchResponseBuilder.build()));
final Invocation.Builder request = resources.getJerseyTest()
.target("/v1/key-transparency/search")
@ -158,54 +172,51 @@ public class KeyTransparencyControllerTest {
final KeyTransparencySearchResponse keyTransparencySearchResponse = response.readEntity(
KeyTransparencySearchResponse.class);
assertNotNull(keyTransparencySearchResponse.fullTreeHead());
assertNotNull(keyTransparencySearchResponse.aciSearchResponse());
usernameHash.ifPresentOrElse(
ignored -> assertTrue(keyTransparencySearchResponse.usernameHashSearchResponse().isPresent()),
() -> assertTrue(keyTransparencySearchResponse.usernameHashSearchResponse().isEmpty()));
assertEquals(aciSearchResponse, TreeSearchResponse.parseFrom(keyTransparencySearchResponse.aciSearchResponse()));
e164.ifPresentOrElse(ignored -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isPresent()),
() -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isEmpty()));
e164.ifPresent(ignored -> assertNotNull(keyTransparencySearchResponse.e164SearchResponse()));
usernameHash.ifPresent(ignored -> assertNotNull(keyTransparencySearchResponse.usernameHashSearchResponse()));
ArgumentCaptor<ByteString> valueArguments = ArgumentCaptor.forClass(ByteString.class);
ArgumentCaptor<ByteString> searchKeyArguments = ArgumentCaptor.forClass(ByteString.class);
ArgumentCaptor<Optional<ByteString>> unidentifiedAccessKeyArgument = ArgumentCaptor.forClass(Optional.class);
ArgumentCaptor<ByteString> aciArgument = ArgumentCaptor.forClass(ByteString.class);
ArgumentCaptor<ByteString> aciIdentityKeyArgument = ArgumentCaptor.forClass(ByteString.class);
ArgumentCaptor<Optional<ByteString>> usernameHashArgument = ArgumentCaptor.forClass(Optional.class);
ArgumentCaptor<Optional<E164SearchRequest>> e164Argument = ArgumentCaptor.forClass(Optional.class);
verify(keyTransparencyServiceClient, times(expectedNumClientCalls)).search(searchKeyArguments.capture(), valueArguments.capture(), unidentifiedAccessKeyArgument.capture(), eq(Optional.of(3L)), eq(Optional.of(4L)),
verify(keyTransparencyServiceClient).search(aciArgument.capture(), aciIdentityKeyArgument.capture(),
usernameHashArgument.capture(), e164Argument.capture(), eq(Optional.of(3L)), eq(Optional.of(4L)),
eq(KeyTransparencyController.KEY_TRANSPARENCY_RPC_TIMEOUT));
assertEquals(expectedSearchKeys, new HashSet<>(searchKeyArguments.getAllValues()));
assertEquals(expectedValues, new HashSet<>(valueArguments.getAllValues()));
assertEquals(expectedUnidentifiedAccessKey, unidentifiedAccessKeyArgument.getAllValues());
assertArrayEquals(ACI.toCompactByteArray(), aciArgument.getValue().toByteArray());
assertArrayEquals(ACI_IDENTITY_KEY.serialize(), aciIdentityKeyArgument.getValue().toByteArray());
if (usernameHash.isPresent()) {
assertArrayEquals(USERNAME_HASH, usernameHashArgument.getValue().orElseThrow().toByteArray());
} else {
assertTrue(usernameHashArgument.getValue().isEmpty());
}
if (e164.isPresent()) {
final E164SearchRequest expected = E164SearchRequest.newBuilder()
.setE164(e164.get())
.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey.get()))
.build();
assertEquals(expected, e164Argument.getValue().orElseThrow());
} else {
assertTrue(e164Argument.getValue().isEmpty());
}
} catch (InvalidProtocolBufferException e) {
throw new RuntimeException(e);
}
}
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(
// Only looking up ACI; ACI identity key should be the only value provided; no UAK
Arguments.of(Optional.empty(), Optional.empty(), 1,
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))))
Arguments.of(Optional.of(NUMBER), Optional.empty()),
Arguments.of(Optional.empty(), Optional.of(USERNAME_HASH)),
Arguments.of(Optional.of(NUMBER), Optional.of(USERNAME_HASH))
);
}
@ -226,7 +237,7 @@ public class KeyTransparencyControllerTest {
@ParameterizedTest
@MethodSource
void searchGrpcErrors(final Status grpcStatus, final int httpStatus) {
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), any()))
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), any(), any()))
.thenReturn(CompletableFuture.failedFuture(new CompletionException(new StatusRuntimeException(grpcStatus))));
final Invocation.Builder request = resources.getJerseyTest()
@ -236,7 +247,7 @@ public class KeyTransparencyControllerTest {
Entity.json(createRequestJson(new KeyTransparencySearchRequest(ACI, Optional.empty(), Optional.empty(),
ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.empty()))))) {
assertEquals(httpStatus, response.getStatus());
verify(keyTransparencyServiceClient, times(1)).search(any(), any(), any(), any(), any(), any());
verify(keyTransparencyServiceClient, times(1)).search(any(), any(), any(), any(), any(), any(), any());
}
}