0
0
mirror of https://github.com/signalapp/Signal-Server.git synced 2024-09-20 03:52:16 +02:00

Create attachments V3 endpoint for CDN2 on GCP

In preparation for resumable uploads, this creates a separate
attachment authorization endpoint that creates a signed URL for
accessing GCP Storage through Signal's CDN2. This should allow Signal
clients to do byte-level resume of media uploads.
This commit is contained in:
Ehren Kret 2020-03-18 09:47:30 -07:00
parent 2aca007a59
commit 41286650cc
12 changed files with 634 additions and 45 deletions

View File

@ -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:

View File

@ -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() {

View File

@ -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<WhisperServerConfiguration> {
@ -243,24 +288,25 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.lifecycle().manage(accountDatabaseCrawler);
environment.lifecycle().manage(remoteConfigsManager);
AWSCredentials credentials = new BasicAWSCredentials(config.getCdnConfiguration().getAccessKey(), config.getCdnConfiguration().getAccessSecret());
AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
AmazonS3 cdnS3Client = AmazonS3Client.builder().withCredentials(credentialsProvider).withRegion(config.getCdnConfiguration().getRegion()).build();
PostPolicyGenerator cdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket(), config.getCdnConfiguration().getAccessKey());
PolicySigner cdnPolicySigner = new PolicySigner(config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion());
AWSCredentials credentials = new BasicAWSCredentials(config.getCdnConfiguration().getAccessKey(), config.getCdnConfiguration().getAccessSecret());
AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
AmazonS3 cdnS3Client = AmazonS3Client.builder().withCredentials(credentialsProvider).withRegion(config.getCdnConfiguration().getRegion()).build();
PostPolicyGenerator profileCdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket(), config.getCdnConfiguration().getAccessKey());
PolicySigner profileCdnPolicySigner = new PolicySigner(config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion());
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret());
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
boolean isZkEnabled = config.getZkConfig().isEnabled();
ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret());
ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);
ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);
boolean isZkEnabled = config.getZkConfig().isEnabled();
AttachmentControllerV1 attachmentControllerV1 = new AttachmentControllerV1(rateLimiters, config.getAttachmentsConfiguration().getAccessKey(), config.getAttachmentsConfiguration().getAccessSecret(), config.getAttachmentsConfiguration().getBucket() );
AttachmentControllerV2 attachmentControllerV2 = new AttachmentControllerV2(rateLimiters, config.getAttachmentsConfiguration().getAccessKey(), config.getAttachmentsConfiguration().getAccessSecret(), config.getAttachmentsConfiguration().getRegion(), config.getAttachmentsConfiguration().getBucket());
KeysController keysController = new KeysController(rateLimiters, keys, accountsManager, directoryQueue);
MessageController messageController = new MessageController(rateLimiters, pushSender, receiptSender, accountsManager, messagesManager, apnFallbackManager);
ProfileController profileController = new ProfileController(rateLimiters, accountsManager, profilesManager, usernamesManager, cdnS3Client, cdnPolicyGenerator, cdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, isZkEnabled);
StickerController stickerController = new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(), config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket());
RemoteConfigController remoteConfigController = new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().getAuthorizedTokens());
AttachmentControllerV1 attachmentControllerV1 = new AttachmentControllerV1(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getBucket());
AttachmentControllerV2 attachmentControllerV2 = new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket());
AttachmentControllerV3 attachmentControllerV3 = new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey());
KeysController keysController = new KeysController(rateLimiters, keys, accountsManager, directoryQueue);
MessageController messageController = new MessageController(rateLimiters, pushSender, receiptSender, accountsManager, messagesManager, apnFallbackManager);
ProfileController profileController = new ProfileController(rateLimiters, accountsManager, profilesManager, usernamesManager, cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, config.getCdnConfiguration().getBucket(), zkProfileOperations, isZkEnabled);
StickerController stickerController = new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(), config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket());
RemoteConfigController remoteConfigController = new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().getAuthorizedTokens());
AuthFilter<BasicCredentials, Account> accountAuthFilter = new BasicCredentialAuthFilter.Builder<Account>().setAuthenticator(accountAuthenticator).buildAuthFilter ();
AuthFilter<BasicCredentials, DisabledPermittedAccount> disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder<DisabledPermittedAccount>().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter();
@ -279,6 +325,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(new SecureBackupController(backupCredentialsGenerator));
environment.jersey().register(attachmentControllerV1);
environment.jersey().register(attachmentControllerV2);
environment.jersey().register(attachmentControllerV3);
environment.jersey().register(keysController);
environment.jersey().register(messageController);
environment.jersey().register(profileController);
@ -294,6 +341,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
webSocketEnvironment.jersey().register(profileController);
webSocketEnvironment.jersey().register(attachmentControllerV1);
webSocketEnvironment.jersey().register(attachmentControllerV2);
webSocketEnvironment.jersey().register(attachmentControllerV3);
webSocketEnvironment.jersey().register(remoteConfigController);
WebSocketEnvironment<Account> provisioningEnvironment = new WebSocketEnvironment<>(environment, webSocketEnvironment.getRequestLog(), 60000);

View File

@ -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

View File

@ -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("/");
}
}

View File

@ -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<String, String> getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) {
Map<String, String> 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;
}
}

View File

@ -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<String, String> headers;
@JsonProperty
private String signedUploadLocation;
public AttachmentDescriptorV3() {
}
public AttachmentDescriptorV3(int cdn, String key, Map<String, String> 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<String, String> getHeaders() {
return headers;
}
public String getSignedUploadLocation() {
return signedUploadLocation;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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<String, String> 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 {