diff --git a/service/config/sample.yml b/service/config/sample.yml index cd4a92d7c..81fc97896 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -56,10 +56,18 @@ messageStore: # Postgresql database configuration for message store password: url: -attachments: # AWS S3 configuration +awsAttachments: # AWS S3 configuration accessKey: accessSecret: bucket: + region: + +gcpAttachments: # GCP Storage configuration + domain: + email: + maxSizeInBytes: + pathPrefix: + rsaSigningKey: profiles: # AWS S3 configuration accessKey: diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 6dfffd2f0..c77316279 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -46,7 +46,12 @@ public class WhisperServerConfiguration extends Configuration { @NotNull @Valid @JsonProperty - private AttachmentsConfiguration attachments; + private AwsAttachmentsConfiguration awsAttachments; + + @NotNull + @Valid + @JsonProperty + private GcpAttachmentsConfiguration gcpAttachments; @NotNull @Valid @@ -199,8 +204,12 @@ public class WhisperServerConfiguration extends Configuration { return httpClient; } - public AttachmentsConfiguration getAttachmentsConfiguration() { - return attachments; + public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() { + return awsAttachments; + } + + public GcpAttachmentsConfiguration getGcpAttachmentsConfiguration() { + return gcpAttachments; } public RedisConfiguration getCacheConfiguration() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 0fbcd1de4..690129f67 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -29,6 +29,17 @@ import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.DeserializationFeature; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import io.dropwizard.Application; +import io.dropwizard.auth.AuthFilter; +import io.dropwizard.auth.PolymorphicAuthDynamicFeature; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.auth.basic.BasicCredentialAuthFilter; +import io.dropwizard.auth.basic.BasicCredentials; +import io.dropwizard.db.DataSourceFactory; +import io.dropwizard.db.PooledDataSourceFactory; +import io.dropwizard.jdbi3.JdbiFactory; +import io.dropwizard.setup.Bootstrap; +import io.dropwizard.setup.Environment; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.eclipse.jetty.servlets.CrossOriginFilter; import org.jdbi.v3.core.Jdbi; @@ -42,7 +53,23 @@ import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; -import org.whispersystems.textsecuregcm.controllers.*; +import org.whispersystems.textsecuregcm.controllers.AccountController; +import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1; +import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; +import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; +import org.whispersystems.textsecuregcm.controllers.CertificateController; +import org.whispersystems.textsecuregcm.controllers.DeviceController; +import org.whispersystems.textsecuregcm.controllers.DirectoryController; +import org.whispersystems.textsecuregcm.controllers.KeepAliveController; +import org.whispersystems.textsecuregcm.controllers.KeysController; +import org.whispersystems.textsecuregcm.controllers.MessageController; +import org.whispersystems.textsecuregcm.controllers.ProfileController; +import org.whispersystems.textsecuregcm.controllers.ProvisioningController; +import org.whispersystems.textsecuregcm.controllers.RemoteConfigController; +import org.whispersystems.textsecuregcm.controllers.SecureBackupController; +import org.whispersystems.textsecuregcm.controllers.SecureStorageController; +import org.whispersystems.textsecuregcm.controllers.StickerController; +import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle; import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; @@ -69,7 +96,36 @@ import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.TwilioSmsSender; import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.storage.*; +import org.whispersystems.textsecuregcm.storage.AbusiveHostRules; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountCleaner; +import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawler; +import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerCache; +import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerListener; +import org.whispersystems.textsecuregcm.storage.Accounts; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.ActiveUserCounter; +import org.whispersystems.textsecuregcm.storage.DirectoryManager; +import org.whispersystems.textsecuregcm.storage.DirectoryReconciler; +import org.whispersystems.textsecuregcm.storage.DirectoryReconciliationClient; +import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase; +import org.whispersystems.textsecuregcm.storage.Keys; +import org.whispersystems.textsecuregcm.storage.Messages; +import org.whispersystems.textsecuregcm.storage.MessagesCache; +import org.whispersystems.textsecuregcm.storage.MessagesManager; +import org.whispersystems.textsecuregcm.storage.PendingAccounts; +import org.whispersystems.textsecuregcm.storage.PendingAccountsManager; +import org.whispersystems.textsecuregcm.storage.PendingDevices; +import org.whispersystems.textsecuregcm.storage.PendingDevicesManager; +import org.whispersystems.textsecuregcm.storage.Profiles; +import org.whispersystems.textsecuregcm.storage.ProfilesManager; +import org.whispersystems.textsecuregcm.storage.PubSubManager; +import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor; +import org.whispersystems.textsecuregcm.storage.RemoteConfigs; +import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; +import org.whispersystems.textsecuregcm.storage.ReservedUsernames; +import org.whispersystems.textsecuregcm.storage.Usernames; +import org.whispersystems.textsecuregcm.storage.UsernamesManager; import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener; import org.whispersystems.textsecuregcm.websocket.DeadLetterHandler; @@ -91,17 +147,6 @@ import java.util.List; import java.util.Optional; import static com.codahale.metrics.MetricRegistry.name; -import io.dropwizard.Application; -import io.dropwizard.auth.AuthFilter; -import io.dropwizard.auth.PolymorphicAuthDynamicFeature; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.auth.basic.BasicCredentialAuthFilter; -import io.dropwizard.auth.basic.BasicCredentials; -import io.dropwizard.db.DataSourceFactory; -import io.dropwizard.db.PooledDataSourceFactory; -import io.dropwizard.jdbi3.JdbiFactory; -import io.dropwizard.setup.Bootstrap; -import io.dropwizard.setup.Environment; public class WhisperServerService extends Application { @@ -243,24 +288,25 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter (); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter(); @@ -279,6 +325,7 @@ public class WhisperServerService extends Application provisioningEnvironment = new WebSocketEnvironment<>(environment, webSocketEnvironment.getRequestLog(), 60000); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AttachmentsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsAttachmentsConfiguration.java similarity index 96% rename from service/src/main/java/org/whispersystems/textsecuregcm/configuration/AttachmentsConfiguration.java rename to service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsAttachmentsConfiguration.java index 55c47bb10..648b37d6a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AttachmentsConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsAttachmentsConfiguration.java @@ -19,7 +19,7 @@ package org.whispersystems.textsecuregcm.configuration; import com.fasterxml.jackson.annotation.JsonProperty; import org.hibernate.validator.constraints.NotEmpty; -public class AttachmentsConfiguration { +public class AwsAttachmentsConfiguration { @NotEmpty @JsonProperty diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java new file mode 100644 index 000000000..315f9aa65 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java @@ -0,0 +1,56 @@ +package org.whispersystems.textsecuregcm.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.dropwizard.util.Strings; +import io.dropwizard.validation.ValidationMethod; +import org.hibernate.validator.constraints.NotEmpty; + +import javax.validation.constraints.Min; + +public class GcpAttachmentsConfiguration { + + @NotEmpty + @JsonProperty + private String domain; + + @NotEmpty + @JsonProperty + private String email; + + @JsonProperty + @Min(1) + private int maxSizeInBytes; + + @JsonProperty + private String pathPrefix; + + @NotEmpty + @JsonProperty + private String rsaSigningKey; + + public String getDomain() { + return domain; + } + + public String getEmail() { + return email; + } + + public int getMaxSizeInBytes() { + return maxSizeInBytes; + } + + public String getPathPrefix() { + return pathPrefix; + } + + public String getRsaSigningKey() { + return rsaSigningKey; + } + + @SuppressWarnings("unused") + @ValidationMethod(message = "pathPrefix must be empty or start with /") + public boolean isPathPrefixValid() { + return Strings.isNullOrEmpty(pathPrefix) || pathPrefix.startsWith("/"); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java new file mode 100644 index 000000000..ab2ce9250 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java @@ -0,0 +1,71 @@ +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import io.dropwizard.auth.Auth; +import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; +import org.whispersystems.textsecuregcm.gcp.CanonicalRequest; +import org.whispersystems.textsecuregcm.gcp.CanonicalRequestGenerator; +import org.whispersystems.textsecuregcm.gcp.CanonicalRequestSigner; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.Account; + +import javax.annotation.Nonnull; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; + +@Path("/v3/attachments") +public class AttachmentControllerV3 extends AttachmentControllerBase { + + @Nonnull + private final RateLimiter rateLimiter; + + @Nonnull + private final CanonicalRequestGenerator canonicalRequestGenerator; + + @Nonnull + private final CanonicalRequestSigner canonicalRequestSigner; + + public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull String domain, @Nonnull String email, int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey) + throws IOException, InvalidKeyException { + this.rateLimiter = rateLimiters.getAttachmentLimiter(); + this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix); + this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey); + } + + @Timed + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/form/upload") + public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth Account account) throws RateLimitExceededException { + rateLimiter.validate(account.getNumber()); + + final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + final String key = String.valueOf(generateAttachmentId()); + final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now); + + return new AttachmentDescriptorV3(2, key, getHeaderMap(canonicalRequest), getSignedUploadLocation(canonicalRequest)); + } + + public String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) { + return "https://" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath() + + '?' + canonicalRequest.getCanonicalQuery() + + "&X-Goog-Signature=" + canonicalRequestSigner.sign(canonicalRequest); + } + + public static Map getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) { + Map result = new HashMap<>(3); + result.put("host", canonicalRequest.getDomain()); + result.put("x-goog-content-length-range", "1," + canonicalRequest.getMaxSizeInBytes()); + result.put("x-goog-resumable", "start"); + return result; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java new file mode 100644 index 000000000..846fff616 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java @@ -0,0 +1,46 @@ +package org.whispersystems.textsecuregcm.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +public class AttachmentDescriptorV3 { + + @JsonProperty + private int cdn; + + @JsonProperty + private String key; + + @JsonProperty + private Map headers; + + @JsonProperty + private String signedUploadLocation; + + public AttachmentDescriptorV3() { + } + + public AttachmentDescriptorV3(int cdn, String key, Map headers, String signedUploadLocation) { + this.cdn = cdn; + this.key = key; + this.headers = headers; + this.signedUploadLocation = signedUploadLocation; + } + + public int getCdn() { + return cdn; + } + + public String getKey() { + return key; + } + + public Map getHeaders() { + return headers; + } + + public String getSignedUploadLocation() { + return signedUploadLocation; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java new file mode 100644 index 000000000..e3104b5b0 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java @@ -0,0 +1,70 @@ +package org.whispersystems.textsecuregcm.gcp; + +import javax.annotation.Nonnull; + +public class CanonicalRequest { + + @Nonnull + private final String canonicalRequest; + + @Nonnull + private final String resourcePath; + + @Nonnull + private final String canonicalQuery; + + @Nonnull + private final String activeDatetime; + + @Nonnull + private final String credentialScope; + + @Nonnull + private final String domain; + + private final int maxSizeInBytes; + + public CanonicalRequest(@Nonnull String canonicalRequest, @Nonnull String resourcePath, @Nonnull String canonicalQuery, @Nonnull String activeDatetime, @Nonnull String credentialScope, @Nonnull String domain, int maxSizeInBytes) { + this.canonicalRequest = canonicalRequest; + this.resourcePath = resourcePath; + this.canonicalQuery = canonicalQuery; + this.activeDatetime = activeDatetime; + this.credentialScope = credentialScope; + this.domain = domain; + this.maxSizeInBytes = maxSizeInBytes; + } + + @Nonnull + String getCanonicalRequest() { + return canonicalRequest; + } + + @Nonnull + public String getResourcePath() { + return resourcePath; + } + + @Nonnull + public String getCanonicalQuery() { + return canonicalQuery; + } + + @Nonnull + String getActiveDatetime() { + return activeDatetime; + } + + @Nonnull + String getCredentialScope() { + return credentialScope; + } + + @Nonnull + public String getDomain() { + return domain; + } + + public int getMaxSizeInBytes() { + return maxSizeInBytes; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java new file mode 100644 index 000000000..8bdf92f5a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java @@ -0,0 +1,75 @@ +package org.whispersystems.textsecuregcm.gcp; + +import io.dropwizard.util.Strings; + +import javax.annotation.Nonnull; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Locale; + +public class CanonicalRequestGenerator { + private static final DateTimeFormatter SIMPLE_UTC_DATE = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.US).withZone(ZoneOffset.UTC); + private static final DateTimeFormatter SIMPLE_UTC_DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.US).withZone(ZoneOffset.UTC); + + @Nonnull + private final String domain; + + @Nonnull + private final String email; + + private final int maxSizeBytes; + + @Nonnull + private final String pathPrefix; + + public CanonicalRequestGenerator(@Nonnull String domain, @Nonnull String email, int maxSizeBytes, @Nonnull String pathPrefix) { + this.domain = domain; + this.email = email; + this.maxSizeBytes = maxSizeBytes; + this.pathPrefix = pathPrefix; + } + + public CanonicalRequest createFor(@Nonnull final String key, @Nonnull final ZonedDateTime now) { + final StringBuilder result = new StringBuilder("POST\n"); + + final StringBuilder resourcePathBuilder = new StringBuilder(); + if (!Strings.isNullOrEmpty(pathPrefix)) { + resourcePathBuilder.append(pathPrefix); + } + resourcePathBuilder.append('/').append(URLEncoder.encode(key, StandardCharsets.UTF_8)); + final String resourcePath = resourcePathBuilder.toString(); + result.append(resourcePath).append('\n'); + + final String activeDatetime = SIMPLE_UTC_DATE_TIME.format(now); + final String canonicalQuery = "X-Goog-Algorithm=GOOG4-RSA-SHA256" + + "&X-Goog-Credential=" + URLEncoder.encode(makeCredential(email, now), StandardCharsets.UTF_8) + + "&X-Goog-Date=" + URLEncoder.encode(activeDatetime, StandardCharsets.UTF_8) + + "&X-Goog-Expires=" + Duration.of(25, ChronoUnit.HOURS).toSeconds() + + "&X-Goog-SignedHeaders=host%3Bx-goog-content-length-range%3Bx-goog-resumable"; + result.append(canonicalQuery).append('\n'); + + result.append("host:").append(domain).append('\n'); + result.append("x-goog-content-length-range:1,").append(maxSizeBytes).append('\n'); + result.append("x-goog-resumable:start\n"); + result.append('\n'); + + result.append("host;x-goog-content-length-range;x-goog-resumable\n"); + + result.append("UNSIGNED-PAYLOAD"); + + return new CanonicalRequest(result.toString(), resourcePath, canonicalQuery, activeDatetime, makeCredentialScope(now), domain, maxSizeBytes); + } + + private String makeCredentialScope(@Nonnull ZonedDateTime now) { + return SIMPLE_UTC_DATE.format(now) + "/auto/storage/goog4_request"; + } + + private String makeCredential(@Nonnull String email, @Nonnull ZonedDateTime now) { + return email + '/' + makeCredentialScope(now); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java new file mode 100644 index 000000000..5590d7456 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java @@ -0,0 +1,78 @@ +package org.whispersystems.textsecuregcm.gcp; + +import org.apache.commons.codec.binary.Hex; +import org.bouncycastle.openssl.PEMReader; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.SignatureException; + +public class CanonicalRequestSigner { + + @Nonnull + private final PrivateKey rsaSigningKey; + + public CanonicalRequestSigner(@Nonnull String rsaSigningKey) throws IOException, InvalidKeyException { + this.rsaSigningKey = initializeRsaSigningKey(rsaSigningKey); + } + + public String sign(@Nonnull CanonicalRequest canonicalRequest) { + return sign(makeStringToSign(canonicalRequest)); + } + + private String makeStringToSign(@Nonnull final CanonicalRequest canonicalRequest) { + final StringBuilder result = new StringBuilder("GOOG4-RSA-SHA256\n"); + + result.append(canonicalRequest.getActiveDatetime()).append('\n'); + + result.append(canonicalRequest.getCredentialScope()).append('\n'); + + final MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + sha256.update(canonicalRequest.getCanonicalRequest().getBytes(StandardCharsets.UTF_8)); + result.append(Hex.encodeHex(sha256.digest())); + + return result.toString(); + } + + private String sign(@Nonnull String stringToSign) { + final byte[] signature; + try { + final Signature sha256rsa = Signature.getInstance("SHA256WITHRSA"); + sha256rsa.initSign(rsaSigningKey); + sha256rsa.update(stringToSign.getBytes(StandardCharsets.UTF_8)); + signature = sha256rsa.sign(); + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + throw new AssertionError(e); + } + return Hex.encodeHexString(signature); + } + + private static PrivateKey initializeRsaSigningKey(String rsaSigningKey) throws IOException, InvalidKeyException { + final PEMReader pemReader = new PEMReader(new StringReader(rsaSigningKey)); + final PrivateKey key = (PrivateKey) pemReader.readObject(); + testKeyIsValidForSigning(key); + return key; + } + + private static void testKeyIsValidForSigning(PrivateKey key) throws InvalidKeyException { + final Signature sha256rsa; + try { + sha256rsa = Signature.getInstance("SHA256WITHRSA"); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + sha256rsa.initSign(key); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java b/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java index 0f4bb1a98..dfbe247bd 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java @@ -1,7 +1,6 @@ package org.whispersystems.textsecuregcm.s3; import com.amazonaws.util.Base16Lower; -import com.google.common.annotations.VisibleForTesting; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java index e6a27ed3a..06b9364ca 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java @@ -1,14 +1,26 @@ package org.whispersystems.textsecuregcm.tests.controllers; import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit.ResourceTestRule; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.Condition; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMWriter; +import org.bouncycastle.openssl.PKCS8Generator; import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.AfterClass; +import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Test; import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; +import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV1; import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2; +import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; import org.whispersystems.textsecuregcm.entities.AttachmentUri; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; @@ -19,10 +31,20 @@ import org.whispersystems.textsecuregcm.util.SystemMapper; import javax.ws.rs.core.Response; import java.io.IOException; +import java.io.StringWriter; import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.Security; +import java.util.HashMap; +import java.util.Map; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit.ResourceTestRule; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -36,15 +58,122 @@ public class AttachmentControllerTest { when(rateLimiters.getAttachmentLimiter()).thenReturn(rateLimiter); } + public static final String RSA_PRIVATE_KEY_PEM; + + static { + try { + final Provider provider = new BouncyCastleProvider(); + final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", provider); + keyPairGenerator.initialize(1024); + final KeyPair keyPair = keyPairGenerator.generateKeyPair(); + final StringWriter stringWriter = new StringWriter(); + final PEMWriter pemWriter = new PEMWriter(stringWriter); + final PKCS8Generator pkcs8Generator = new PKCS8Generator(keyPair.getPrivate()); + pemWriter.writeObject(pkcs8Generator); + pemWriter.close(); + RSA_PRIVATE_KEY_PEM = stringWriter.toString(); + } catch (NoSuchAlgorithmException | IOException e) { + throw new AssertionError(e); + } + } + @ClassRule - public static final ResourceTestRule resources = ResourceTestRule.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new AttachmentControllerV1(rateLimiters, "accessKey", "accessSecret", "attachment-bucket")) - .addResource(new AttachmentControllerV2(rateLimiters, "accessKey", "accessSecret", "us-east-1", "attachmentv2-bucket")) - .build(); + public static final ResourceTestRule resources; + + static { + try { + Security.insertProviderAt(new BouncyCastleProvider(), 0); + resources = ResourceTestRule.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class))) + .setMapper(SystemMapper.getMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new AttachmentControllerV1(rateLimiters, "accessKey", "accessSecret", "attachment-bucket")) + .addResource(new AttachmentControllerV2(rateLimiters, "accessKey", "accessSecret", "us-east-1", "attachmentv2-bucket")) + .addResource(new AttachmentControllerV3(rateLimiters, "some-cdn.signal.org", "signal@example.com", 1000, "/attach-here", RSA_PRIVATE_KEY_PEM)) + .build(); + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + } catch (IOException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + @BeforeClass + public static void setup() { + Security.insertProviderAt(new BouncyCastleProvider(), 0); + } + + @AfterClass + public static void tearDown() { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + } + + @Test + public void testV3Form() { + AttachmentDescriptorV3 descriptor = resources.getJerseyTest() + .target("/v3/attachments/form/upload") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) + .get(AttachmentDescriptorV3.class); + + assertThat(descriptor.getKey()).isNotBlank(); + assertThat(descriptor.getCdn()).isEqualTo(2); + assertThat(descriptor.getHeaders()).hasSize(3); + assertThat(descriptor.getHeaders()).extractingByKey("host").isEqualTo("some-cdn.signal.org"); + assertThat(descriptor.getHeaders()).extractingByKey("x-goog-resumable").isEqualTo("start"); + assertThat(descriptor.getHeaders()).extractingByKey("x-goog-content-length-range").isEqualTo("1,1000"); + assertThat(descriptor.getSignedUploadLocation()).isNotEmpty(); + assertThat(descriptor.getSignedUploadLocation()).contains("X-Goog-Signature"); + assertThat(descriptor.getSignedUploadLocation()).is(new Condition<>(x -> { + try { + new URL(x); + } catch (MalformedURLException e) { + return false; + } + return true; + }, "convertible to a URL", (Object[]) null)); + + final URL signedUploadLocation; + try { + signedUploadLocation = new URL(descriptor.getSignedUploadLocation()); + } catch (MalformedURLException e) { + throw new AssertionError(e); + } + assertThat(signedUploadLocation.getHost()).isEqualTo("some-cdn.signal.org"); + assertThat(signedUploadLocation.getPath()).startsWith("/attach-here/"); + final Map queryParamMap = new HashMap<>(); + final String[] queryTerms = signedUploadLocation.getQuery().split("&"); + for (final String queryTerm : queryTerms) { + final String[] keyValueArray = queryTerm.split("=", 2); + queryParamMap.put( + URLDecoder.decode(keyValueArray[0], StandardCharsets.UTF_8), + URLDecoder.decode(keyValueArray[1], StandardCharsets.UTF_8)); + } + + assertThat(queryParamMap).extractingByKey("X-Goog-Algorithm").isEqualTo("GOOG4-RSA-SHA256"); + assertThat(queryParamMap).extractingByKey("X-Goog-Expires").isEqualTo("90000"); + assertThat(queryParamMap).extractingByKey("X-Goog-SignedHeaders").isEqualTo("host;x-goog-content-length-range;x-goog-resumable"); + assertThat(queryParamMap).extractingByKey("X-Goog-Date", Assertions.as(InstanceOfAssertFactories.STRING)).isNotEmpty(); + + final String credential = queryParamMap.get("X-Goog-Credential"); + String[] credentialParts = credential.split("/"); + assertThat(credentialParts).hasSize(5); + assertThat(credentialParts[0]).isEqualTo("signal@example.com"); + assertThat(credentialParts[2]).isEqualTo("auto"); + assertThat(credentialParts[3]).isEqualTo("storage"); + assertThat(credentialParts[4]).isEqualTo("goog4_request"); + } + + @Test + public void testV3FormDisabled() { + Response response = resources.getJerseyTest() + .target("/v3/attachments/form/upload") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_NUMBER, AuthHelper.DISABLED_PASSWORD)) + .get(); + + assertThat(response.getStatus()).isEqualTo(401); + } @Test public void testV2Form() throws IOException {