diff --git a/service/config/sample.yml b/service/config/sample.yml index c2b70e204..bbc4bdd20 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -433,6 +433,32 @@ registrationService: AAAAAAAAAAAAAAAAAAAA -----END CERTIFICATE----- +keyTransparencyService: + host: kt.example.com + port: 443 + tlsCertificate: | + -----BEGIN CERTIFICATE----- + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + AAAAAAAAAAAAAAAAAAAA + -----END CERTIFICATE----- + turn: secret: secret://turn.secret cloudflare: diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 477d6c778..7d8d42b6d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -37,6 +37,7 @@ import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.GenericZkConfig; import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory; +import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration; import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration; @@ -341,6 +342,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private ExternalRequestFilterConfiguration externalRequestFilter; + @Valid + @NotNull + @JsonProperty + private KeyTransparencyServiceConfiguration keyTransparencyService; + public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() { return tlsKeyStore; } @@ -567,4 +573,8 @@ public class WhisperServerConfiguration extends Configuration { public ExternalRequestFilterConfiguration getExternalRequestFilterConfiguration() { return externalRequestFilter; } + + public KeyTransparencyServiceConfiguration getKeyTransparencyServiceConfiguration() { + return keyTransparencyService; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 205cd7cb4..72636352a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -119,6 +119,7 @@ import org.whispersystems.textsecuregcm.controllers.DeviceController; import org.whispersystems.textsecuregcm.controllers.DirectoryV2Controller; import org.whispersystems.textsecuregcm.controllers.DonationController; import org.whispersystems.textsecuregcm.controllers.KeepAliveController; +import org.whispersystems.textsecuregcm.controllers.KeyTransparencyController; import org.whispersystems.textsecuregcm.controllers.KeysController; import org.whispersystems.textsecuregcm.controllers.MessageController; import org.whispersystems.textsecuregcm.controllers.PaymentsController; @@ -159,6 +160,7 @@ import org.whispersystems.textsecuregcm.grpc.net.ManagedLocalGrpcServer; import org.whispersystems.textsecuregcm.grpc.net.ManagedNioEventLoopGroup; import org.whispersystems.textsecuregcm.grpc.net.NoiseWebSocketTunnelServer; import org.whispersystems.textsecuregcm.jetty.JettyHttpConfigurationCustomizer; +import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient; import org.whispersystems.textsecuregcm.limits.CardinalityEstimator; import org.whispersystems.textsecuregcm.limits.PushChallengeManager; import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; @@ -572,6 +574,8 @@ public class WhisperServerService extends Application authenticatedAccount, + @NotNull @Valid final KeyTransparencySearchRequest request) { + + // Disallow clients from making authenticated requests to this endpoint + requireNotAuthenticated(authenticatedAccount); + + try { + final CompletableFuture aciSearchKeyResponseFuture = keyTransparencyServiceClient.search( + getFullSearchKeyByteString(ACI_PREFIX, request.aci().toCompactByteArray()), + request.lastTreeHeadSize(), + KEY_TRANSPARENCY_RPC_TIMEOUT); + + final CompletableFuture e164SearchKeyResponseFuture = request.e164() + .map(e164 -> keyTransparencyServiceClient.search( + getFullSearchKeyByteString(E164_PREFIX, e164.getBytes(StandardCharsets.UTF_8)), + request.lastTreeHeadSize(), + KEY_TRANSPARENCY_RPC_TIMEOUT)) + .orElse(CompletableFuture.completedFuture(null)); + + final CompletableFuture usernameHashSearchKeyResponseFuture = request.usernameHash() + .map(usernameHash -> keyTransparencyServiceClient.search( + getFullSearchKeyByteString(USERNAME_PREFIX, request.usernameHash().get()), + request.lastTreeHeadSize(), + 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(); + } catch (final CancellationException exception) { + LOGGER.error("Unexpected cancellation from key transparency service", exception); + throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, exception); + } catch (final CompletionException exception) { + handleKeyTransparencyServiceError(exception); + } + // This is unreachable + return null; + } + + @Operation( + summary = "Monitor the given search keys 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 . + """ + ) + @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 = "413", description = "Ratelimited") + @ApiResponse(responseCode = "422", description = "Invalid request format") + @POST + @Path("/monitor") + @RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP) + @Produces(MediaType.APPLICATION_JSON) + public KeyTransparencyMonitorResponse monitor( + @ReadOnly @Auth final Optional authenticatedAccount, + @NotNull @Valid final KeyTransparencyMonitorRequest request) { + + // Disallow clients from making authenticated requests to this endpoint + requireNotAuthenticated(authenticatedAccount); + + try { + final List monitorKeys = new ArrayList<>(List.of( + createMonitorKey(getFullSearchKeyByteString(ACI_PREFIX, request.aci().toCompactByteArray()), + request.aciPositions()) + )); + + request.usernameHash().ifPresent(usernameHash -> + monitorKeys.add(createMonitorKey(getFullSearchKeyByteString(USERNAME_PREFIX, usernameHash), + request.usernameHashPositions().get())) + ); + + request.e164().ifPresent(e164 -> + monitorKeys.add( + createMonitorKey(getFullSearchKeyByteString(E164_PREFIX, e164.getBytes(StandardCharsets.UTF_8)), + request.e164Positions().get())) + ); + + final MonitorResponse monitorResponse = keyTransparencyServiceClient.monitor( + monitorKeys, + request.lastTreeHeadSize(), + KEY_TRANSPARENCY_RPC_TIMEOUT).join(); + + MonitorProof usernameHashMonitorProof = null; + MonitorProof e164MonitorProof = null; + + // In the future we'll update KT's monitor response structure to enumerate each monitor key proof + // rather than returning everything in a list + if (monitorResponse.getContactProofsCount() == 3) { + e164MonitorProof = monitorResponse.getContactProofs(1); + usernameHashMonitorProof = monitorResponse.getContactProofs(2); + } else if (monitorResponse.getContactProofsCount() == 2) { + if (request.usernameHash().isPresent()) { + usernameHashMonitorProof = monitorResponse.getContactProofs(1); + } else if (request.e164().isPresent()) { + e164MonitorProof = monitorResponse.getContactProofs(1); + } + } + return new KeyTransparencyMonitorResponse(monitorResponse.getTreeHead(), + monitorResponse.getContactProofs(0), + Optional.ofNullable(e164MonitorProof), + Optional.ofNullable(usernameHashMonitorProof), + monitorResponse.getInclusionList().stream().map(ByteString::toByteArray).toList()); + } catch (final CancellationException exception) { + LOGGER.error("Unexpected cancellation from key transparency service", exception); + throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, exception); + } catch (final CompletionException exception) { + handleKeyTransparencyServiceError(exception); + } + // This is unreachable + return null; + } + + private void handleKeyTransparencyServiceError(final CompletionException exception) { + final Throwable unwrapped = ExceptionUtils.unwrap(exception); + + if (unwrapped instanceof StatusRuntimeException e) { + final Status.Code code = e.getStatus().getCode(); + final String description = e.getStatus().getDescription(); + switch (code) { + case NOT_FOUND -> throw new NotFoundException(description); + case PERMISSION_DENIED -> throw new ForbiddenException(description); + case INVALID_ARGUMENT -> throw new WebApplicationException(description, 422); + default -> throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, unwrapped); + } + } + LOGGER.error("Unexpected key transparency service failure", unwrapped); + throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, unwrapped); + } + + private static MonitorKey createMonitorKey(final ByteString fullSearchKey, final List positions) { + return MonitorKey.newBuilder() + .setSearchKey(fullSearchKey) + .addAllEntries(positions) + .build(); + } + + private void requireNotAuthenticated(final Optional authenticatedAccount) { + if (authenticatedAccount.isPresent()) { + throw new BadRequestException("Endpoint requires unauthenticated access"); + } + } + + @VisibleForTesting + static ByteString getFullSearchKeyByteString(final byte prefix, final byte[] searchKeyBytes) { + final ByteBuffer fullSearchKeyBuffer = ByteBuffer.allocate(searchKeyBytes.length + 1); + fullSearchKeyBuffer.put(prefix); + fullSearchKeyBuffer.put(searchKeyBytes); + fullSearchKeyBuffer.flip(); + + return ByteString.copyFrom(fullSearchKeyBuffer.array()); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorRequest.java new file mode 100644 index 000000000..6dd6e8ca9 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorRequest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; +import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import java.util.List; +import java.util.Optional; + +public record KeyTransparencyMonitorRequest( + @NotNull + @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class) + @JsonDeserialize(using = ServiceIdentifierAdapter.AciServiceIdentifierDeserializer.class) + @Schema(description = "The aci identifier to monitor") + AciServiceIdentifier aci, + + @NotEmpty + @Schema(description = "A list of log tree positions maintained by the client for the aci search key.") + List<@Positive Long> aciPositions, + + @Schema(description = "The e164-formatted phone number to monitor") + Optional e164, + + @Schema(description = "A list of log tree positions maintained by the client for the e164 search key.") + Optional> e164Positions, + + @JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class) + @JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class) + @Schema(description = "The username hash to monitor, encoded in url-safe unpadded base64.") + Optional usernameHash, + + @Schema(description = "A list of log tree positions maintained by the client for the username hash search key.") + Optional> usernameHashPositions, + + @Schema(description = "The tree head size to prove consistency against.") + Optional<@Positive Long> lastTreeHeadSize +) { + + @AssertTrue + public boolean isUsernameHashFieldsValid() { + return (usernameHash.isEmpty() && usernameHashPositions.isEmpty()) || + (usernameHash.isPresent() && usernameHashPositions.isPresent() && !usernameHashPositions.get().isEmpty()); + } + + @AssertTrue + public boolean isE164VFieldsValid() { + return (e164.isEmpty() && e164Positions.isEmpty()) || + (e164.isPresent() && e164Positions.isPresent() && !e164Positions.get().isEmpty()); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorResponse.java new file mode 100644 index 000000000..a60447403 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorResponse.java @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import katie.FullTreeHead; +import katie.MonitorProof; +import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; +import org.whispersystems.textsecuregcm.util.FullTreeHeadProtobufAdapter; +import org.whispersystems.textsecuregcm.util.MonitorProofProtobufAdapter; + +import javax.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; + +public record KeyTransparencyMonitorResponse( + @NotNull + @JsonSerialize(using = FullTreeHeadProtobufAdapter.Serializer.class) + @JsonDeserialize(using = FullTreeHeadProtobufAdapter.Deserializer.class) + @Schema(description = """ + The key transparency log's tree head along with a consistency proof and possibly an auditor-signed tree head + """) + FullTreeHead fullTreeHead, + + @NotNull + @JsonSerialize(using = MonitorProofProtobufAdapter.Serializer.class) + @JsonDeserialize(using = MonitorProofProtobufAdapter.Deserializer.class) + @Schema(description = "The monitor proof for the aci search key") + MonitorProof aciMonitorProof, + + @JsonSerialize(contentUsing = MonitorProofProtobufAdapter.Serializer.class) + @JsonDeserialize(contentUsing = MonitorProofProtobufAdapter.Deserializer.class) + @Schema(description = "The monitor proof for the e164 search key") + Optional e164MonitorProof, + + @JsonSerialize(contentUsing = MonitorProofProtobufAdapter.Serializer.class) + @JsonDeserialize(contentUsing = MonitorProofProtobufAdapter.Deserializer.class) + @Schema(description = "The monitor proof for the username hash search key") + Optional usernameHashMonitorProof, + + @NotNull + @JsonSerialize(contentUsing = ByteArrayAdapter.Serializing.class) + @JsonDeserialize(contentUsing = ByteArrayAdapter.Deserializing.class) + @Schema(description = "A list of hashes encoded in standard, unpadded base64 that prove inclusion across all monitor proofs ") + List inclusionProof +) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java new file mode 100644 index 000000000..e56af0827 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; +import org.whispersystems.textsecuregcm.util.E164; +import org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; +import java.util.Optional; + +public record KeyTransparencySearchRequest( + @NotNull + @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class) + @JsonDeserialize(using = ServiceIdentifierAdapter.AciServiceIdentifierDeserializer.class) + @Schema(description = "The aci identifier to look up") + AciServiceIdentifier aci, + + @E164 + @Schema(description = "The e164-formatted phone number to look up") + Optional e164, + + @JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class) + @JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class) + @Schema(description = "The username hash to look up, encoded in web-safe unpadded base64.") + Optional usernameHash, + + @Schema(description = "The tree head size to prove consistency against.") + Optional<@Positive Long> lastTreeHeadSize +) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java new file mode 100644 index 000000000..700ecda23 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import katie.SearchResponse; +import org.whispersystems.textsecuregcm.util.SearchResponseProtobufAdapter; + +import javax.validation.constraints.NotNull; +import java.util.Optional; + +public record KeyTransparencySearchResponse( + @NotNull + @JsonSerialize(using = SearchResponseProtobufAdapter.Serializer.class) + @JsonDeserialize(using = SearchResponseProtobufAdapter.Deserializer.class) + @Schema(description = "The search response for the aci search key") + SearchResponse aciSearchResponse, + + @JsonSerialize(contentUsing = SearchResponseProtobufAdapter.Serializer.class) + @JsonDeserialize(contentUsing = SearchResponseProtobufAdapter.Deserializer.class) + @Schema(description = "The search response for the e164 search key") + Optional e164SearchResponse, + + @JsonSerialize(contentUsing = SearchResponseProtobufAdapter.Serializer.class) + @JsonDeserialize(contentUsing = SearchResponseProtobufAdapter.Deserializer.class) + @Schema(description = "The search response for the username hash search key") + 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 new file mode 100644 index 000000000..ea5e9931d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/keytransparency/KeyTransparencyServiceClient.java @@ -0,0 +1,98 @@ +package org.whispersystems.textsecuregcm.keytransparency; + +import com.google.protobuf.ByteString; +import io.dropwizard.lifecycle.Managed; +import io.grpc.ChannelCredentials; +import io.grpc.Deadline; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +import io.grpc.TlsChannelCredentials; +import katie.KatieGrpc; +import katie.MonitorKey; +import katie.MonitorRequest; +import katie.MonitorResponse; +import katie.SearchRequest; +import katie.SearchResponse; +import org.whispersystems.textsecuregcm.util.CompletableFutureUtil; + +public class KeyTransparencyServiceClient implements Managed { + + private final Executor callbackExecutor; + private final String host; + private final int port; + private final ChannelCredentials tlsChannelCredentials; + private ManagedChannel channel; + private KatieGrpc.KatieFutureStub stub; + + public KeyTransparencyServiceClient( + final String host, + final int port, + final String tlsCertificate, + final Executor callbackExecutor + ) throws IOException { + this.host = host; + this.port = port; + try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream( + tlsCertificate.getBytes(StandardCharsets.UTF_8))) { + tlsChannelCredentials = TlsChannelCredentials.newBuilder() + .trustManager(certificateInputStream) + .build(); + } + this.callbackExecutor = callbackExecutor; + } + + public CompletableFuture search( + final ByteString searchKey, + final Optional lastTreeHeadSize, + final Duration timeout) { + final SearchRequest.Builder searchRequestBuilder = SearchRequest.newBuilder() + .setSearchKey(searchKey); + + lastTreeHeadSize.ifPresent(searchRequestBuilder::setLast); + + return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + .search(searchRequestBuilder.build()), callbackExecutor); + } + + public CompletableFuture monitor(final List monitorKeys, + final Optional lastTreeHeadSize, + final Duration timeout) { + final MonitorRequest.Builder monitorRequestBuilder = MonitorRequest.newBuilder() + .addAllContactKeys(monitorKeys); + + lastTreeHeadSize.ifPresent(monitorRequestBuilder::setLast); + + return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + .monitor(monitorRequestBuilder.build()), callbackExecutor); + } + + private static Deadline toDeadline(final Duration timeout) { + return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public void start() throws Exception { + channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials) + .idleTimeout(1, TimeUnit.MINUTES) + .build(); + stub = KatieGrpc.newFutureStub(channel); + } + + @Override + public void stop() throws Exception { + if (channel != null) { + channel.shutdown(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index 05f8facc2..7b6133c1a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -48,6 +48,8 @@ public class RateLimiters extends BaseRateLimiters { CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))), INBOUND_MESSAGE_BYTES("inboundMessageBytes", true, new RateLimiterConfig(128 * 1024 * 1024, Duration.ofNanos(500_000))), EXTERNAL_SERVICE_CREDENTIALS("externalServiceCredentials", true, new RateLimiterConfig(100, Duration.ofMinutes(15))), + KEY_TRANSPARENCY_SEARCH_PER_IP("keyTransparencySearch", true, new RateLimiterConfig(100, Duration.ofSeconds(15))), + KEY_TRANSPARENCY_MONITOR_PER_IP("keyTransparencyMonitor", true, new RateLimiterConfig(100, Duration.ofSeconds(15))), ; private final String id; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java index de8d33cb5..dda712b46 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java @@ -1,8 +1,5 @@ package org.whispersystems.textsecuregcm.registration; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.Phonenumber; @@ -34,6 +31,7 @@ import org.signal.registration.rpc.SendVerificationCodeRequest; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.controllers.VerificationSessionRateLimitExceededException; import org.whispersystems.textsecuregcm.entities.RegistrationServiceSession; +import org.whispersystems.textsecuregcm.util.CompletableFutureUtil; public class RegistrationServiceClient implements Managed { @@ -84,11 +82,11 @@ public class RegistrationServiceClient implements Managed { final long e164 = Long.parseLong( PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1)); - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)) .createSession(CreateRegistrationSessionRequest.newBuilder() .setE164(e164) .setAccountExistsWithE164(accountExistsWithPhoneNumber) - .build())) + .build()), callbackExecutor) .thenApply(response -> switch (response.getResponseCase()) { case SESSION_METADATA -> buildSessionResponseFromMetadata(response.getSessionMetadata()); @@ -129,8 +127,8 @@ public class RegistrationServiceClient implements Managed { requestBuilder.setSenderName(senderOverride); } - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) - .sendVerificationCode(requestBuilder.build())) + return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + .sendVerificationCode(requestBuilder.build()), callbackExecutor) .thenApply(response -> { if (response.hasError()) { switch (response.getError().getErrorType()) { @@ -172,11 +170,11 @@ public class RegistrationServiceClient implements Managed { public CompletableFuture checkVerificationCode(final byte[] sessionId, final String verificationCode, final Duration timeout) { - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)) .checkVerificationCode(CheckVerificationCodeRequest.newBuilder() .setSessionId(ByteString.copyFrom(sessionId)) .setVerificationCode(verificationCode) - .build())) + .build()), callbackExecutor) .thenApply(response -> { if (response.hasError()) { switch (response.getError().getErrorType()) { @@ -208,9 +206,9 @@ public class RegistrationServiceClient implements Managed { public CompletableFuture> getSession(final byte[] sessionId, final Duration timeout) { - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)).getSessionMetadata( + return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)).getSessionMetadata( GetRegistrationSessionMetadataRequest.newBuilder() - .setSessionId(ByteString.copyFrom(sessionId)).build())) + .setSessionId(ByteString.copyFrom(sessionId)).build()), callbackExecutor) .thenApply(response -> { if (response.hasError()) { switch (response.getError().getErrorType()) { @@ -251,24 +249,6 @@ public class RegistrationServiceClient implements Managed { }; } - private CompletableFuture toCompletableFuture(final ListenableFuture listenableFuture) { - final CompletableFuture completableFuture = new CompletableFuture<>(); - - Futures.addCallback(listenableFuture, new FutureCallback() { - @Override - public void onSuccess(@Nullable final T result) { - completableFuture.complete(result); - } - - @Override - public void onFailure(final Throwable throwable) { - completableFuture.completeExceptionally(throwable); - } - }, callbackExecutor); - - return completableFuture; - } - @Override public void start() throws Exception { } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java new file mode 100644 index 000000000..3d7f79466 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import javax.annotation.Nullable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public class CompletableFutureUtil { + + public static CompletableFuture toCompletableFuture(final ListenableFuture listenableFuture, + final Executor callbackExecutor) { + final CompletableFuture completableFuture = new CompletableFuture<>(); + + Futures.addCallback(listenableFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable final T result) { + completableFuture.complete(result); + } + + @Override + public void onFailure(final Throwable throwable) { + completableFuture.completeExceptionally(throwable); + } + }, callbackExecutor); + + return completableFuture; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java index 6dab03ea7..ac9f9df8a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java @@ -14,6 +14,7 @@ import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.util.Objects; +import java.util.Optional; import javax.validation.Constraint; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; @@ -25,7 +26,10 @@ import javax.validation.Payload; */ @Target({ FIELD, PARAMETER, METHOD }) @Retention(RUNTIME) -@Constraint(validatedBy = E164.Validator.class) +@Constraint(validatedBy = { + E164.Validator.class, + E164.OptionalValidator.class +}) @Documented public @interface E164 { @@ -53,4 +57,12 @@ public @interface E164 { return true; } } + + class OptionalValidator implements ConstraintValidator> { + + @Override + public boolean isValid(final Optional value, final ConstraintValidatorContext context) { + return value.map(s -> new Validator().isValid(s, context)).orElse(true); + } + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/FullTreeHeadProtobufAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/FullTreeHeadProtobufAdapter.java new file mode 100644 index 000000000..a48c76985 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/FullTreeHeadProtobufAdapter.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import katie.FullTreeHead; + +public class FullTreeHeadProtobufAdapter { + + public static class Serializer extends ProtobufAdapter.Serializer {} + + public static class Deserializer extends ProtobufAdapter.Deserializer { + + public Deserializer() { + super(FullTreeHead::newBuilder); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/MonitorProofProtobufAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/MonitorProofProtobufAdapter.java new file mode 100644 index 000000000..612272354 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/MonitorProofProtobufAdapter.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import katie.MonitorProof; + +public class MonitorProofProtobufAdapter { + + public static class Serializer extends ProtobufAdapter.Serializer {} + + public static class Deserializer extends ProtobufAdapter.Deserializer { + + public Deserializer() { + super(MonitorProof::newBuilder); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ProtobufAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ProtobufAdapter.java new file mode 100644 index 000000000..19c678a2b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/ProtobufAdapter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; + +import java.io.IOException; +import java.util.function.Supplier; + +public class ProtobufAdapter { + + public static class Serializer extends JsonSerializer { + + @Override + public void serialize(T message, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + jsonGenerator.writeString(JsonFormat.printer().print(message)); + } + } + + public static class Deserializer extends JsonDeserializer { + + private final Supplier builderSupplier; + + public Deserializer(Supplier builderSupplier) { + this.builderSupplier = builderSupplier; + } + + @Override + public T deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + Message.Builder builder = builderSupplier.get(); + JsonFormat.parser().ignoringUnknownFields().merge(jsonParser.getValueAsString(), builder); + return (T) builder.build(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/SearchResponseProtobufAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/SearchResponseProtobufAdapter.java new file mode 100644 index 000000000..2be09aade --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/util/SearchResponseProtobufAdapter.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import katie.SearchResponse; + +public class SearchResponseProtobufAdapter { + + public static class Serializer extends ProtobufAdapter.Serializer {} + + public static class Deserializer extends ProtobufAdapter.Deserializer { + + public Deserializer() { + super(SearchResponse::newBuilder); + } + } +} diff --git a/service/src/main/proto/KeyTransparencyService.proto b/service/src/main/proto/KeyTransparencyService.proto new file mode 100644 index 000000000..bb3122947 --- /dev/null +++ b/service/src/main/proto/KeyTransparencyService.proto @@ -0,0 +1,241 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package katie; + +service Katie { + /** + * Provides proof that a specific version of the search key exists in the log tree. + */ + rpc Search(SearchRequest) returns (SearchResponse) {} + /** + * Allows users to monitor a set of search keys by providing proof that the log tree continues to be + * constructed correctly in later entries for those search keys. + */ + rpc Monitor(MonitorRequest) returns (MonitorResponse) {} +} + +// 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 { + /** + * 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. + */ + bytes search_key = 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. + */ + optional uint32 version = 2; + /** + * The tree head size to prove consistency against. + */ + optional uint64 last = 3; +} + +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; + /** + * A proof that is combined with the original requested search key 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. + */ + 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. + */ + bytes opening = 4; + /** + * The new or updated value of the search key. + */ + UpdateValue value = 5; +} + +message FullTreeHead { + /** + * A representation of the log tree's current state signed by the key transparency service. + */ + TreeHead tree_head = 1; + /** + * A consistency proof between the current tree size and the requested tree size. + */ + repeated bytes consistency = 2; + /** + * A tree head signed by a third-party auditor. + */ + optional AuditorTreeHead auditor_tree_head = 3; +} + +message TreeHead { + /** + * The number of entries in the log tree. + */ + uint64 tree_size = 1; + /** + * The time in milliseconds since epoch when the tree head signature was generated. + */ + int64 timestamp = 2; + /** + * A signature computed over the log tree's current state and long-term log configuration. + */ + bytes signature = 3; +} + +message AuditorTreeHead { + /** + * A representation of the log tree state signed by a third-party auditor. + */ + TreeHead tree_head = 1; + /** + * Provided if the auditor tree head size is smaller than the size of the most recent + * tree head provided to the user. + * The root hash of the log tree when the auditor produced the tree head signature. + */ + optional bytes root_value = 2; + /** + * Provided if the auditor tree head size is smaller than the size of the most recent + * tree head provided by the key transparency service to the user. + * A consistency proof between the auditor tree head and the most recent tree head. + */ + repeated bytes consistency = 3; +} + +/** + * A ProofStep represents one "step" or log entry in the binary search + * and can be used to calculate a log tree leaf hash. + */ +message ProofStep { + /** + * Provides the data needed to recompute the prefix tree root hash corresponding to the given log entry. + */ + PrefixSearchResult prefix = 1; + /** + * A cryptographic hash of the update used to calculate the log tree leaf hash. + */ + bytes commitment = 2; +} + +message SearchProof { + /** + * The position in the log tree of the first occurrence of the requested search key. + */ + uint64 pos = 1; + /** + * The steps of a binary search through the entries of the log tree for the given search key 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. + */ + repeated bytes inclusion = 3; +} + + +message UpdateValue { + /** + * TODO: Update KT server to remove this field since it's only relevant to third-party management and we're not doing that. + */ + // optional bytes signature = 1; + /** + * The new value for a search key. + */ + bytes value = 2; +} + +message PrefixSearchResult { + /** + * A proof from a prefix tree that indicates a search was done correctly for a given search key. + * 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. + */ + uint32 counter = 2; +} + +message MonitorKey { + /** + * The key to search for in the log tree. + */ + bytes search_key = 1; + /** + * A list of log tree positions maintained by a client for the search key 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 + * in the corresponding MonitorProof. + */ + repeated uint64 entries = 2; +} + + +message MonitorRequest { + /** + * TODO: Remove this protobuf field in the KT server + */ + 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. + */ + repeated MonitorKey contact_keys = 2; + /** + * The tree head size that the key transparency server must prove consistency against. + */ + optional uint64 last = 3; +} + +message MonitorProof { + /** + * Generated based on the monitored entries provided in MonitorKey.entries. Each ProofStep + * corresponds to a log tree entry that exists in the search path to each monitored entry + * and that came *after* that monitored entry. It proves that the log tree has been constructed + * correctly at that later entry. This list also includes any remaining entries + * along the "frontier" of the log tree which proves that the very last entry in the log + * has been constructed correctly. + */ + repeated ProofStep steps = 1; +} + +message MonitorResponse { + /** + * 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; + /** + * TODO: Remove this protobuf field in the KT server + */ + 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. + */ + repeated MonitorProof contact_proofs = 3; + /** + * A batch inclusion proof that the log entries involved in the binary search for each of the entries + * being monitored in MonitorKey.entries are included in the current log tree. + */ + repeated bytes inclusion = 4; +} + diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java new file mode 100644 index 000000000..9870149b7 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java @@ -0,0 +1,390 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.net.HttpHeaders; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +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 katie.FullTreeHead; +import katie.MonitorProof; +import katie.MonitorResponse; +import katie.SearchResponse; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorRequest; +import org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorResponse; +import org.whispersystems.textsecuregcm.entities.KeyTransparencySearchRequest; +import org.whispersystems.textsecuregcm.entities.KeyTransparencySearchResponse; +import org.whispersystems.textsecuregcm.identity.AciServiceIdentifier; +import org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient; +import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.MockUtils; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; +import org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.Invocation; +import javax.ws.rs.core.Response; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.reset; +import static org.mockito.Mockito.when; + +@ExtendWith(DropwizardExtensionsSupport.class) +public class KeyTransparencyControllerTest { + + private static final String NUMBER = PhoneNumberUtil.getInstance().format( + PhoneNumberUtil.getInstance().getExampleNumber("US"), + PhoneNumberUtil.PhoneNumberFormat.E164); + private static final AciServiceIdentifier ACI = new AciServiceIdentifier(UUID.randomUUID()); + private static final TestRemoteAddressFilterProvider TEST_REMOTE_ADDRESS_FILTER_PROVIDER + = new TestRemoteAddressFilterProvider("127.0.0.1"); + private final KeyTransparencyServiceClient keyTransparencyServiceClient = mock(KeyTransparencyServiceClient.class); + private static final RateLimiters rateLimiters = mock(RateLimiters.class); + private static final RateLimiter searchRatelimiter = mock(RateLimiter.class); + private static final RateLimiter monitorRatelimiter = mock(RateLimiter.class); + + private final ResourceExtension resources = ResourceExtension.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class)) + .addProvider(TEST_REMOTE_ADDRESS_FILTER_PROVIDER) + .addProvider(new RateLimitByIpFilter(rateLimiters)) + .setMapper(SystemMapper.jsonMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new KeyTransparencyController(keyTransparencyServiceClient)) + .build(); + + @BeforeEach + void setup() { + when(rateLimiters.forDescriptor(eq(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP))).thenReturn(searchRatelimiter); + when(rateLimiters.forDescriptor(eq(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP))).thenReturn( + monitorRatelimiter); + } + + @AfterEach + void teardown() { + reset(rateLimiters, + searchRatelimiter, + monitorRatelimiter); + } + + @Test + void getFullSearchKey() { + final byte[] charBytes = new byte[]{KeyTransparencyController.ACI_PREFIX}; + final byte[] aci = ACI.toCompactByteArray(); + + final byte[] expectedFullSearchKey = new byte[aci.length + 1]; + System.arraycopy(charBytes, 0, expectedFullSearchKey, 0, charBytes.length); + System.arraycopy(aci, 0, expectedFullSearchKey, charBytes.length, aci.length); + + assertArrayEquals(expectedFullSearchKey, + KeyTransparencyController.getFullSearchKeyByteString(KeyTransparencyController.ACI_PREFIX, aci).toByteArray()); + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + @ParameterizedTest + @MethodSource + void searchSuccess(final Optional e164, final Optional usernameHash) { + final SearchResponse searchResponse = SearchResponse.newBuilder().build(); + when(keyTransparencyServiceClient.search(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(searchResponse)); + + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/search") + .request(); + try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, e164, usernameHash)))) { + assertEquals(200, response.getStatus()); + + final KeyTransparencySearchResponse keyTransparencySearchResponse = response.readEntity( + KeyTransparencySearchResponse.class); + assertNotNull(keyTransparencySearchResponse.aciSearchResponse()); + + usernameHash.ifPresentOrElse( + ignored -> assertTrue(keyTransparencySearchResponse.usernameHashSearchResponse().isPresent()), + () -> assertTrue(keyTransparencySearchResponse.usernameHashSearchResponse().isEmpty())); + + e164.ifPresentOrElse(ignored -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isPresent()), + () -> assertTrue(keyTransparencySearchResponse.e164SearchResponse().isEmpty())); + } + } + + private static Stream searchSuccess() { + return Stream.of( + Arguments.of(Optional.empty(), Optional.empty()), + Arguments.of(Optional.empty(), Optional.of(TestRandomUtil.nextBytes(20))), + Arguments.of(Optional.of(NUMBER), Optional.empty()) + ); + } + + @Test + void searchAuthenticated() { + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/search") + .request() + .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); + try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty())))) { + assertEquals(400, response.getStatus()); + } + } + + @ParameterizedTest + @MethodSource + void searchGrpcErrors(final Status grpcStatus, final int httpStatus) { + when(keyTransparencyServiceClient.search(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new CompletionException(new StatusRuntimeException(grpcStatus)))); + + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/search") + .request(); + try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty())))) { + assertEquals(httpStatus, response.getStatus()); + } + } + + private static Stream searchGrpcErrors() { + return Stream.of( + Arguments.of(Status.NOT_FOUND, 404), + Arguments.of(Status.PERMISSION_DENIED, 403), + Arguments.of(Status.INVALID_ARGUMENT, 422), + Arguments.of(Status.UNKNOWN, 500) + ); + } + + @Test + void searchInvalidRequest() { + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/search") + .request(); + try (Response response = request.post(Entity.json( + // ACI can't be null + createSearchRequestJson(null, Optional.empty(), Optional.empty())))) { + assertEquals(422, response.getStatus()); + } + } + + @Test + void searchRatelimited() { + MockUtils.updateRateLimiterResponseToFail( + rateLimiters, RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP, "127.0.0.1", Duration.ofMinutes(10), true); + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/search") + .request(); + try (Response response = request.post(Entity.json(createSearchRequestJson(ACI, Optional.empty(), Optional.empty())))) { + assertEquals(429, response.getStatus()); + } + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + @ParameterizedTest + @MethodSource + void monitorSuccess( + final Optional e164, + final Optional> e164Positions, + final Optional usernameHash, + final Optional> usernameHashPositions) { + final List monitorProofs = new ArrayList<>(List.of(MonitorProof.newBuilder().build())); + e164.ifPresent(ignored -> monitorProofs.add(MonitorProof.newBuilder().build())); + usernameHash.ifPresent(ignored -> monitorProofs.add(MonitorProof.newBuilder().build())); + + final MonitorResponse monitorResponse = MonitorResponse.newBuilder() + .setTreeHead(FullTreeHead.newBuilder().build()) + .addAllContactProofs(monitorProofs) + .build(); + + when(keyTransparencyServiceClient.monitor(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(monitorResponse)); + + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/monitor") + .request(); + + try (Response response = request.post(Entity.json( + createMonitorRequestJson( + ACI, List.of(3L), + usernameHash, usernameHashPositions, + e164, e164Positions)))) { + assertEquals(200, response.getStatus()); + + final KeyTransparencyMonitorResponse keyTransparencyMonitorResponse = response.readEntity( + KeyTransparencyMonitorResponse.class); + assertNotNull(keyTransparencyMonitorResponse.aciMonitorProof()); + + usernameHash.ifPresentOrElse( + ignored -> assertTrue(keyTransparencyMonitorResponse.usernameHashMonitorProof().isPresent()), + () -> assertTrue(keyTransparencyMonitorResponse.usernameHashMonitorProof().isEmpty())); + + e164.ifPresentOrElse(ignored -> assertTrue(keyTransparencyMonitorResponse.e164MonitorProof().isPresent()), + () -> assertTrue(keyTransparencyMonitorResponse.e164MonitorProof().isEmpty())); + } + } + + private static Stream monitorSuccess() { + return Stream.of( + Arguments.of(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()), + Arguments.of(Optional.empty(), Optional.empty(), Optional.of(TestRandomUtil.nextBytes(20)), Optional.of(List.of(3L))), + Arguments.of(Optional.of(NUMBER), Optional.of(List.of(3L)), Optional.empty(), Optional.empty()) + ); + } + + @Test + void monitorAuthenticated() { + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/monitor") + .request() + .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); + try (Response response = request.post( + Entity.json(createMonitorRequestJson(ACI, List.of(3L), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty())))) { + assertEquals(400, response.getStatus()); + } + } + + @ParameterizedTest + @MethodSource + void monitorGrpcErrors(final Status grpcStatus, final int httpStatus) { + when(keyTransparencyServiceClient.monitor(any(), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new CompletionException(new StatusRuntimeException(grpcStatus)))); + + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/monitor") + .request(); + try (Response response = request.post( + Entity.json(createMonitorRequestJson(ACI, List.of(3L), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty())))) { + assertEquals(httpStatus, response.getStatus()); + } + } + + private static Stream monitorGrpcErrors() { + return Stream.of( + Arguments.of(Status.NOT_FOUND, 404), + Arguments.of(Status.PERMISSION_DENIED, 403), + Arguments.of(Status.INVALID_ARGUMENT, 422), + Arguments.of(Status.UNKNOWN, 500) + ); + } + + @ParameterizedTest + @MethodSource + void monitorInvalidRequest(final String requestJson) { + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/monitor") + .request(); + try (Response response = request.post(Entity.json(requestJson))) { + assertEquals(422, response.getStatus()); + } + } + + private static Stream monitorInvalidRequest() { + return Stream.of( + // aci and aciPositions can't be empty + Arguments.of(createMonitorRequestJson(null, null, Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty())), + // aciPositions list can't be empty + Arguments.of(createMonitorRequestJson(ACI, Collections.emptyList(), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty())), + // usernameHash cannot be empty if usernameHashPositions isn't + Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.empty(), Optional.of(List.of(5L)), + Optional.empty(), Optional.empty())), + // usernameHashPosition cannot be empty if usernameHash isn't + Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.of(TestRandomUtil.nextBytes(20)), + Optional.empty(), Optional.empty(), Optional.empty())), + // usernameHashPositions list cannot be empty + Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.of(TestRandomUtil.nextBytes(20)), + Optional.of(Collections.emptyList()), Optional.empty(), Optional.empty())), + // e164 cannot be empty if e164Positions isn't + Arguments.of( + createMonitorRequestJson(ACI, List.of(4L), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.of(List.of(5L)))), + // e164Positions cannot be empty if e164 isn't + Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.empty(), + Optional.empty(), Optional.of(NUMBER), Optional.empty())), + // e164Positions list cannot empty + Arguments.of(createMonitorRequestJson(ACI, List.of(4L), Optional.empty(), + Optional.empty(), Optional.of(NUMBER), Optional.of(Collections.emptyList()))) + ); + } + + @Test + void monitorRatelimited() { + MockUtils.updateRateLimiterResponseToFail( + rateLimiters, RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP, "127.0.0.1", Duration.ofMinutes(10), true); + final Invocation.Builder request = resources.getJerseyTest() + .target("/v1/key-transparency/monitor") + .request(); + try (Response response = request.post( + Entity.json(createMonitorRequestJson(ACI, List.of(3L), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty())))) { + assertEquals(429, response.getStatus()); + } + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + /** + * Create an invalid monitor request by supplying an invalid combination of inputs. For example, providing + * a username hash but no corresponding list of positions. + */ + private static String createMonitorRequestJson( + final AciServiceIdentifier aci, + final List aciPositions, + final Optional usernameHash, + final Optional> usernameHashPositions, + final Optional e164, + final Optional> e164Positions) { + final KeyTransparencyMonitorRequest request = new KeyTransparencyMonitorRequest(aci, aciPositions, + e164, e164Positions, usernameHash, usernameHashPositions, Optional.empty()); + try { + return SystemMapper.jsonMapper().writeValueAsString(request); + } catch (final JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private static String createSearchRequestJson( + final AciServiceIdentifier aci, + final Optional e164, + final Optional usernameHash) { + final KeyTransparencySearchRequest request = new KeyTransparencySearchRequest(aci, e164, usernameHash, null); + try { + return SystemMapper.jsonMapper().writeValueAsString(request); + } catch (final JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java index 8901e00df..ba8d388f3 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Method; +import java.util.Optional; import java.util.Set; import javax.validation.ConstraintViolation; import javax.validation.Validation; @@ -31,14 +32,18 @@ public class E164Test { @E164 private final String number; - private Data(final String number) { + @E164 + private final Optional optionalNumber; + + private Data(final String number, final Optional optionalNumber) { this.number = number; + this.optionalNumber = optionalNumber; } } private static class Methods { - public void foo(@E164 final String number) { + public void foo(@E164 final String number, @E164 final Optional optionalNumber) { // noop } @@ -46,36 +51,41 @@ public class E164Test { public String bar() { return "nevermind"; } + + @E164 + public Optional barOptionalString() { + return Optional.of("nevermind"); + } } - private record Rec(@E164 String number) { + private record Rec(@E164 String number, @E164 Optional optionalNumber) { } @Test - public void testRecord() throws Exception { - checkNoViolations(new Rec(E164_VALID)); - checkHasViolations(new Rec(E164_INVALID)); - checkHasViolations(new Rec(EMPTY)); + public void testRecord() { + checkNoViolations(new Rec(E164_VALID, Optional.of(E164_VALID))); + checkHasViolations(new Rec(E164_INVALID, Optional.of(E164_INVALID))); + checkHasViolations(new Rec(EMPTY, Optional.of(EMPTY))); } @Test - public void testClassField() throws Exception { - checkNoViolations(new Data(E164_VALID)); - checkHasViolations(new Data(E164_INVALID)); - checkHasViolations(new Data(EMPTY)); + public void testClassField() { + checkNoViolations(new Data(E164_VALID, Optional.of(E164_VALID))); + checkHasViolations(new Data(E164_INVALID, Optional.of(E164_INVALID))); + checkHasViolations(new Data(EMPTY, Optional.of(EMPTY))); } @Test public void testParameters() throws Exception { final Methods m = new Methods(); - final Method foo = Methods.class.getMethod("foo", String.class); + final Method foo = Methods.class.getMethod("foo", String.class, Optional.class); final Set> violations1 = - VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_VALID}); + VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_VALID, Optional.of(E164_VALID)}); final Set> violations2 = - VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_INVALID}); + VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_INVALID, Optional.of(E164_INVALID)}); final Set> violations3 = - VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {EMPTY}); + VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {EMPTY, Optional.of(EMPTY)}); assertTrue(violations1.isEmpty()); assertFalse(violations2.isEmpty()); @@ -99,6 +109,23 @@ public class E164Test { assertFalse(violations3.isEmpty()); } + @Test + public void testOptionalReturnValue() throws Exception { + final Methods m = new Methods(); + final Method bar = Methods.class.getMethod("barOptionalString"); + + final Set> violations1 = + VALIDATOR.forExecutables().validateReturnValue(m, bar, Optional.of(E164_VALID)); + final Set> violations2 = + VALIDATOR.forExecutables().validateReturnValue(m, bar, Optional.of(E164_INVALID)); + final Set> violations3 = + VALIDATOR.forExecutables().validateReturnValue(m, bar, Optional.of(EMPTY)); + + assertTrue(violations1.isEmpty()); + assertFalse(violations2.isEmpty()); + assertFalse(violations3.isEmpty()); + } + private static void checkNoViolations(final T object) { final Set> violations = VALIDATOR.validate(object); assertTrue(violations.isEmpty()); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/ProtobufAdapterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/ProtobufAdapterTest.java new file mode 100644 index 000000000..5573c7023 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/util/ProtobufAdapterTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.util; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.google.protobuf.ByteString; +import katie.FullTreeHead; +import katie.TreeHead; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProtobufAdapterTest { + private record FullTreeHeadTestRecord(@JsonSerialize(using = FullTreeHeadProtobufAdapter.Serializer.class) + @JsonDeserialize(using = FullTreeHeadProtobufAdapter.Deserializer.class) + FullTreeHead fullTreeHead) { + } + + @Test + void serializeDeserialize() throws JsonProcessingException { + final TreeHead treeHead = TreeHead.newBuilder() + .setTreeSize(10) + .setTimestamp(12345) + .setSignature(ByteString.copyFrom(TestRandomUtil.nextBytes(16))) + .build(); + + final FullTreeHead fullTreeHead = FullTreeHead.newBuilder() + .setTreeHead(treeHead) + .addAllConsistency(List.of(ByteString.copyFrom(TestRandomUtil.nextBytes(20)))) + .build(); + + final FullTreeHeadTestRecord expectedTestRecord = new FullTreeHeadTestRecord(fullTreeHead); + + // Serialize to JSON + final String json = SystemMapper.jsonMapper().writeValueAsString(expectedTestRecord); + + // Deserialize back to record + assertEquals(expectedTestRecord, SystemMapper.jsonMapper().readValue(json, FullTreeHeadTestRecord.class)); + } + + @Test + void deserializeFailure() { + assertThrows(JsonParseException.class, + () -> SystemMapper.jsonMapper().readValue("this is not valid json", FullTreeHeadTestRecord.class)); + } +} diff --git a/service/src/test/proto/test_tree_head.proto b/service/src/test/proto/test_tree_head.proto new file mode 100644 index 000000000..95bc2d6a1 --- /dev/null +++ b/service/src/test/proto/test_tree_head.proto @@ -0,0 +1,18 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.rpc; + +message TestTreeHead { + uint64 tree_size = 1; + int64 timestamp = 2; + bytes signature = 3; + // Test that the deserializer properly ignores unknown fields + bytes unknown_field = 4; +} diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 0454a7f1d..0634c00e9 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -422,6 +422,32 @@ registrationService: 9Kxq0DY7RCEpdHMCKcOL -----END CERTIFICATE----- +keyTransparencyService: + host: kt.example.com + port: 443 + tlsCertificate: | + -----BEGIN CERTIFICATE----- + MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL + BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM + GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz + MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw + HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB + AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD + 2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8 + ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP + ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq + llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH + c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud + DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0 + SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw + ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h + rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP + UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ + 6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58 + O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd + 9Kxq0DY7RCEpdHMCKcOL + -----END CERTIFICATE----- + turn: secret: secret://turn.secret cloudflare: