diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyController.java index 82734575b..a48f8ea8c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyController.java @@ -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 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 maybeE164SearchRequest = + request.e164().flatMap(e164 -> request.unidentifiedAccessKey().map(uak -> + E164SearchRequest.newBuilder() + .setE164(e164) + .setUnidentifiedAccessKey(ByteString.copyFrom(request.unidentifiedAccessKey().get())) + .build() + )); - final CompletableFuture 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 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) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java index ec15cf91e..1bff881b9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java @@ -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 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) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java index 9b3bda6d8..468eb8eca 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java @@ -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 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 usernameHashSearchResponse ) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/keytransparency/KeyTransparencyServiceClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/keytransparency/KeyTransparencyServiceClient.java index 7f8abbc2f..2f21ba9b3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/keytransparency/KeyTransparencyServiceClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/keytransparency/KeyTransparencyServiceClient.java @@ -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 search( - final ByteString searchKey, - final ByteString mappedValue, - final Optional unidentifiedAccessKey, + public CompletableFuture search( + final ByteString aci, + final ByteString aciIdentityKey, + final Optional usernameHash, + final Optional e164SearchRequest, final Optional lastTreeHeadSize, final Optional 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") diff --git a/service/src/main/proto/KeyTransparencyService.proto b/service/src/main/proto/KeyTransparencyService.proto index 3ba4c60f0..bad88a23d 100644 --- a/service/src/main/proto/KeyTransparencyService.proto +++ b/service/src/main/proto/KeyTransparencyService.proto @@ -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; /** diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java index fa7500b23..0df1a327b 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java @@ -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 e164, final Optional usernameHash, final int expectedNumClientCalls, - final Set expectedSearchKeys, - final Set expectedValues, - final List> expectedUnidentifiedAccessKey) { - when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(TestRandomUtil.nextBytes(16))); + void searchSuccess(final Optional e164, final Optional 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 valueArguments = ArgumentCaptor.forClass(ByteString.class); - ArgumentCaptor searchKeyArguments = ArgumentCaptor.forClass(ByteString.class); - ArgumentCaptor> unidentifiedAccessKeyArgument = ArgumentCaptor.forClass(Optional.class); + ArgumentCaptor aciArgument = ArgumentCaptor.forClass(ByteString.class); + ArgumentCaptor aciIdentityKeyArgument = ArgumentCaptor.forClass(ByteString.class); + ArgumentCaptor> usernameHashArgument = ArgumentCaptor.forClass(Optional.class); + ArgumentCaptor> 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 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()); } }