From 257fef973490f6ed69dcad0e6007f562999f20c1 Mon Sep 17 00:00:00 2001 From: Jon Chambers Date: Wed, 31 Mar 2021 11:11:10 -0400 Subject: [PATCH] Add a secure backup service client. --- .../SecureBackupServiceConfiguration.java | 47 +++++++ .../securebackup/SecureBackupClient.java | 70 ++++++++++ .../securebackup/SecureBackupException.java | 13 ++ .../securebackup/SecureBackupClientTest.java | 124 ++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java index de81a4de6..e7fbe7dd2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java @@ -6,7 +6,11 @@ package org.whispersystems.textsecuregcm.configuration; import com.fasterxml.jackson.annotation.JsonProperty; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import com.google.common.annotations.VisibleForTesting; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Hex; @@ -16,8 +20,51 @@ public class SecureBackupServiceConfiguration { @JsonProperty private String userAuthenticationTokenSharedSecret; + @NotBlank + @JsonProperty + private String uri; + + @NotBlank + @JsonProperty + private String backupCaCertificate; + + @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; + } + + @VisibleForTesting + public void setBackupCaCertificate(final String backupCaCertificate) { + this.backupCaCertificate = backupCaCertificate; + } + + public String getBackupCaCertificate() { + return backupCaCertificate; + } + + public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { + return circuitBreaker; + } + + public RetryConfiguration getRetryConfiguration() { + return retry; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java new file mode 100644 index 000000000..667b50b23 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.securebackup; + +import com.google.common.annotations.VisibleForTesting; +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.security.cert.CertificateException; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; +import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; +import org.whispersystems.textsecuregcm.util.Base64; + +/** + * A client for sending requests to Signal's secure value recovery service on behalf of authenticated users. + */ +public class SecureBackupClient { + + private final ExternalServiceCredentialGenerator secureBackupCredentialGenerator; + private final URI deleteUri; + private final FaultTolerantHttpClient httpClient; + + @VisibleForTesting + static final String DELETE_PATH = "/v1/backup"; + + public SecureBackupClient(final ExternalServiceCredentialGenerator secureBackupCredentialGenerator, final Executor executor, final SecureBackupServiceConfiguration configuration) throws CertificateException { + this.secureBackupCredentialGenerator = secureBackupCredentialGenerator; + 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-backup") + .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_2) + .withTrustedServerCertificate(configuration.getBackupCaCertificate()) + .build(); + } + + public CompletableFuture deleteBackups(final UUID accountUuid) { + final ExternalServiceCredentials credentials = secureBackupCredentialGenerator.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 SecureBackupException("Failed to delete backup: " + response.statusCode()); + }); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java b/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java new file mode 100644 index 000000000..2453b74a4 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java @@ -0,0 +1,13 @@ +/* + * Copyright 2020 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.securebackup; + +public class SecureBackupException extends RuntimeException { + + public SecureBackupException(final String message) { + super(message); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java new file mode 100644 index 000000000..5ed972e50 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.securebackup; + +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import org.apache.commons.lang3.RandomStringUtils; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +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.SecureBackupServiceConfiguration; + +import java.security.Security; +import java.security.cert.CertificateException; +import java.util.UUID; +import java.util.concurrent.CompletionException; +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.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SecureBackupClientTest { + + private UUID accountUuid; + private ExternalServiceCredentialGenerator credentialGenerator; + private ExecutorService httpExecutor; + + private SecureBackupClient secureStorageClient; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(options().dynamicPort().dynamicHttpsPort()); + + @BeforeClass + public static void setupBeforeClass() { + Security.addProvider(new BouncyCastleProvider()); + } + + @Before + public void setUp() throws CertificateException { + accountUuid = UUID.randomUUID(); + credentialGenerator = mock(ExternalServiceCredentialGenerator.class); + httpExecutor = Executors.newSingleThreadExecutor(); + + final SecureBackupServiceConfiguration config = new SecureBackupServiceConfiguration(); + config.setUri("http://localhost:" + wireMockRule.port()); + + // This is a randomly-generated, throwaway certificate that's not actually connected to anything + config.setBackupCaCertificate( + "-----BEGIN CERTIFICATE-----\n" + + "MIICZDCCAc2gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBPMQswCQYDVQQGEwJ1czEL\n" + + "MAkGA1UECAwCVVMxHjAcBgNVBAoMFVNpZ25hbCBNZXNzZW5nZXIsIExMQzETMBEG\n" + + "A1UEAwwKc2lnbmFsLm9yZzAeFw0yMDEyMjMyMjQ3NTlaFw0zMDEyMjEyMjQ3NTla\n" + + "ME8xCzAJBgNVBAYTAnVzMQswCQYDVQQIDAJVUzEeMBwGA1UECgwVU2lnbmFsIE1l\n" + + "c3NlbmdlciwgTExDMRMwEQYDVQQDDApzaWduYWwub3JnMIGfMA0GCSqGSIb3DQEB\n" + + "AQUAA4GNADCBiQKBgQCfSLcZNHYqbxSsgWp4JvbPRHjQTrlsrKrgD2q7f/OY6O3Y\n" + + "/X0QNcNSOJpliN8rmzwslfsrXHO3q1diGRw4xHogUJZ/7NQrHiP/zhN0VTDh49pD\n" + + "ZpjXVyUbayLS/6qM5arKxBspzEFBb5v8cF6bPr76SO/rpGXiI0j6yJKX6fRiKwID\n" + + "AQABo1AwTjAdBgNVHQ4EFgQU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwHwYDVR0jBBgw\n" + + "FoAU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B\n" + + "AQ0FAAOBgQB+5d5+NtzLILfrc9QmJdIO1YeDP64JmFwTER0kEUouRsb9UwknVWZa\n" + + "y7MTM4NoBV1k0zb5LAk89SIDPr/maW5AsLtEomzjnEiomjoMBUdNe3YCgQReoLnr\n" + + "R/QaUNbrCjTGYfBsjGbIzmkWPUyTec2ZdRyJ8JiVl386+6CZkxnndQ==\n" + + "-----END CERTIFICATE-----"); + + secureStorageClient = new SecureBackupClient(credentialGenerator, httpExecutor, config); + } + + @After + public void tearDown() throws InterruptedException { + httpExecutor.shutdown(); + httpExecutor.awaitTermination(1, TimeUnit.SECONDS); + } + + @AfterClass + public static void tearDownAfterClass() { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + } + + @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(SecureBackupClient.DELETE_PATH)) + .withBasicAuth(username, password) + .willReturn(aResponse().withStatus(202))); + + // We're happy as long as this doesn't throw an exception + secureStorageClient.deleteBackups(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(SecureBackupClient.DELETE_PATH)) + .withBasicAuth(username, password) + .willReturn(aResponse().withStatus(400))); + + final CompletionException completionException = assertThrows(CompletionException.class, () -> secureStorageClient.deleteBackups(accountUuid).join()); + assertTrue(completionException.getCause() instanceof SecureBackupException); + } +}