diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java index 15bfd7bd4..3171c00ea 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java @@ -6,18 +6,53 @@ package org.whispersystems.textsecuregcm.configuration; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Hex; import org.hibernate.validator.constraints.NotEmpty; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + public class SecureStorageServiceConfiguration { @NotEmpty @JsonProperty private String userAuthenticationTokenSharedSecret; + @NotBlank + @JsonProperty + private String uri; + + @NotNull + @Valid + @JsonProperty + private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration(); + + @NotNull + @Valid + @JsonProperty + private RetryConfiguration retry = new RetryConfiguration(); + public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException { return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray()); } - + + @VisibleForTesting + public void setUri(final String uri) { + this.uri = uri; + } + + public String getUri() { + return uri; + } + + public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { + return circuitBreaker; + } + + public RetryConfiguration getRetryConfiguration() { + return retry; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java new file mode 100644 index 000000000..f3c383731 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java @@ -0,0 +1,68 @@ +/* + * Copyright 2020 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.securestorage; + +import com.google.common.annotations.VisibleForTesting; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.util.Base64; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +/** + * A client for sending requests to Signal's secure storage service on behalf of authenticated users. + */ +public class SecureStorageClient { + + private final ExternalServiceCredentialGenerator storageServiceCredentialGenerator; + private final URI deleteUri; + private final FaultTolerantHttpClient httpClient; + + @VisibleForTesting + static final String DELETE_PATH = "/v1/storage"; + + public SecureStorageClient(final ExternalServiceCredentialGenerator storageServiceCredentialGenerator, final Executor executor, final SecureStorageServiceConfiguration configuration) { + this.storageServiceCredentialGenerator = storageServiceCredentialGenerator; + this.deleteUri = URI.create(configuration.getUri()).resolve(DELETE_PATH); + this.httpClient = FaultTolerantHttpClient.newBuilder() + .withCircuitBreaker(configuration.getCircuitBreakerConfiguration()) + .withRetry(configuration.getRetryConfiguration()) + .withVersion(HttpClient.Version.HTTP_1_1) + .withConnectTimeout(Duration.ofSeconds(10)) + .withRedirect(HttpClient.Redirect.NEVER) + .withExecutor(executor) + .withName("secure-storage") + .build(); + } + + public CompletableFuture deleteStoredData(final UUID accountUuid) { + final ExternalServiceCredentials credentials = storageServiceCredentialGenerator.generateFor(accountUuid.toString()); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(deleteUri) + .DELETE() + .header("Authorization", "Basic " + Base64.encodeBytes((credentials.getUsername() + ":" + credentials.getPassword()).getBytes(StandardCharsets.UTF_8))) + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return null; + } + + throw new RuntimeException("Failed to delete storage service data: " + response.statusCode()); + }); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java new file mode 100644 index 000000000..e4ceb7001 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.securestorage; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; + +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.delete; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SecureStorageClientTest { + + private UUID accountUuid; + private ExternalServiceCredentialGenerator credentialGenerator; + private ExecutorService httpExecutor; + + private SecureStorageClient secureStorageClient; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(options().dynamicPort().dynamicHttpsPort()); + + @Before + public void setUp() { + accountUuid = UUID.randomUUID(); + credentialGenerator = mock(ExternalServiceCredentialGenerator.class); + httpExecutor = Executors.newSingleThreadExecutor(); + + final SecureStorageServiceConfiguration config = new SecureStorageServiceConfiguration(); + config.setUri("http://localhost:" + wireMockRule.port()); + + secureStorageClient = new SecureStorageClient(credentialGenerator, httpExecutor, config); + } + + @After + public void tearDown() throws InterruptedException { + httpExecutor.shutdown(); + httpExecutor.awaitTermination(1, TimeUnit.SECONDS); + } + + @Test + public void deleteStoredData() { + final String username = RandomStringUtils.randomAlphabetic(16); + final String password = RandomStringUtils.randomAlphanumeric(32); + + when(credentialGenerator.generateFor(accountUuid.toString())).thenReturn(new ExternalServiceCredentials(username, password)); + + wireMockRule.stubFor(delete(urlEqualTo(SecureStorageClient.DELETE_PATH)) + .withBasicAuth(username, password) + .willReturn(aResponse().withStatus(202))); + + // We're happy as long as this doesn't throw an exception + secureStorageClient.deleteStoredData(accountUuid).join(); + } + + @Test + public void deleteStoredDataFailure() { + final String username = RandomStringUtils.randomAlphabetic(16); + final String password = RandomStringUtils.randomAlphanumeric(32); + + when(credentialGenerator.generateFor(accountUuid.toString())).thenReturn(new ExternalServiceCredentials(username, password)); + + wireMockRule.stubFor(delete(urlEqualTo(SecureStorageClient.DELETE_PATH)) + .withBasicAuth(username, password) + .willReturn(aResponse().withStatus(400))); + + assertThrows(RuntimeException.class, () -> secureStorageClient.deleteStoredData(accountUuid).join()); + } +}