Support for sticker pack uploads

This commit is contained in:
Moxie Marlinspike 2019-07-03 20:58:38 -07:00
parent 0d46f85ead
commit 10724fee04
13 changed files with 323 additions and 16 deletions

View File

@ -51,7 +51,7 @@ public class WhisperServerConfiguration extends Configuration {
@NotNull
@Valid
@JsonProperty
private ProfilesConfiguration profiles;
private CdnConfiguration cdn;
@NotNull
@Valid
@ -247,8 +247,8 @@ public class WhisperServerConfiguration extends Configuration {
return apn;
}
public ProfilesConfiguration getProfilesConfiguration() {
return profiles;
public CdnConfiguration getCdnConfiguration() {
return cdn;
}
public UnidentifiedDeliveryConfiguration getDeliveryCertificate() {

View File

@ -29,9 +29,9 @@ import org.jdbi.v3.core.Jdbi;
import org.whispersystems.dispatch.DispatchManager;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator;
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.AccountController;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV1;
@ -46,6 +46,7 @@ import org.whispersystems.textsecuregcm.controllers.ProfileController;
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
import org.whispersystems.textsecuregcm.controllers.SecureStorageController;
import org.whispersystems.textsecuregcm.controllers.TransparentDataController;
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
@ -244,7 +245,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
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, config.getProfilesConfiguration());
ProfileController profileController = new ProfileController(rateLimiters, accountsManager, config.getCdnConfiguration());
StickerController stickerController = new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(), config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(), config.getCdnConfiguration().getBucket());
AuthFilter<BasicCredentials, Account> accountAuthFilter = new BasicCredentialAuthFilter.Builder<Account>().setAuthenticator(accountAuthenticator).buildAuthFilter ();
AuthFilter<BasicCredentials, DisabledPermittedAccount> disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder<DisabledPermittedAccount>().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter();
@ -266,6 +268,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(keysController);
environment.jersey().register(messageController);
environment.jersey().register(profileController);
environment.jersey().register(stickerController);
///
WebSocketEnvironment webSocketEnvironment = new WebSocketEnvironment(environment, config.getWebSocketConfiguration(), 90000);

View File

@ -3,7 +3,7 @@ package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
public class ProfilesConfiguration {
public class CdnConfiguration {
@NotEmpty
@JsonProperty
private String accessKey;

View File

@ -68,6 +68,9 @@ public class RateLimitsConfiguration {
@JsonProperty
private RateLimitConfiguration profile = new RateLimitConfiguration(4320, 3);
@JsonProperty
private RateLimitConfiguration stickerPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0));
public RateLimitConfiguration getAutoBlock() {
return autoBlock;
}
@ -132,6 +135,10 @@ public class RateLimitsConfiguration {
return profile;
}
public RateLimitConfiguration getStickerPack() {
return stickerPack;
}
public static class RateLimitConfiguration {
@JsonProperty
private int bucketSize;

View File

@ -41,7 +41,7 @@ public class AttachmentControllerV2 extends AttachmentControllerBase {
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
long attachmentId = generateAttachmentId();
String objectName = String.valueOf(attachmentId);
Pair<String, String> policy = policyGenerator.createFor(now, String.valueOf(objectName));
Pair<String, String> policy = policyGenerator.createFor(now, String.valueOf(objectName), 100 * 1024 * 1024);
String signature = policySigner.getSignature(now, policy.second());
return new AttachmentDescriptorV2(attachmentId, objectName, policy.first(),

View File

@ -13,7 +13,7 @@ import org.hibernate.validator.valuehandling.UnwrapValidatedValue;
import org.whispersystems.textsecuregcm.auth.Anonymous;
import org.whispersystems.textsecuregcm.auth.OptionalAccess;
import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;
import org.whispersystems.textsecuregcm.configuration.ProfilesConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.entities.Profile;
import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -55,7 +55,7 @@ public class ProfileController {
public ProfileController(RateLimiters rateLimiters,
AccountsManager accountsManager,
ProfilesConfiguration profilesConfiguration)
CdnConfiguration profilesConfiguration)
{
AWSCredentials credentials = new BasicAWSCredentials(profilesConfiguration.getAccessKey(), profilesConfiguration.getAccessSecret());
AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
@ -123,7 +123,7 @@ public class ProfileController {
String previousAvatar = account.getAvatar();
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
String objectName = generateAvatarObjectName();
Pair<String, String> policy = policyGenerator.createFor(now, objectName);
Pair<String, String> policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024);
String signature = policySigner.getSignature(now, policy.second());
if (previousAvatar != null && previousAvatar.startsWith("profiles/")) {

View File

@ -0,0 +1,79 @@
package org.whispersystems.textsecuregcm.controllers;
import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes;
import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes.StickerPackFormUploadItem;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.s3.PolicySigner;
import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.util.Hex;
import org.whispersystems.textsecuregcm.util.Pair;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.security.SecureRandom;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.LinkedList;
import java.util.List;
import io.dropwizard.auth.Auth;
@Path("/v1/sticker")
public class StickerController {
private final RateLimiters rateLimiters;
private final PolicySigner policySigner;
private final PostPolicyGenerator policyGenerator;
public StickerController(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {
this.rateLimiters = rateLimiters;
this.policySigner = new PolicySigner(accessSecret, region);
this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/pack/form/{count}")
public StickerPackFormUploadAttributes getStickersForm(@Auth Account account,
@PathParam("count") @Min(1) @Max(50) int stickerCount)
throws RateLimitExceededException
{
rateLimiters.getStickerPackLimiter().validate(account.getNumber());
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
String packId = generatePackId();
String packLocation = "stickers/" + packId;
String manifestKey = packLocation + "/manifest.proto";
Pair<String, String> manifestPolicy = policyGenerator.createFor(now, manifestKey, 1024);
String manifestSignature = policySigner.getSignature(now, manifestPolicy.second());
StickerPackFormUploadItem manifest = new StickerPackFormUploadItem(-1, manifestKey, manifestPolicy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), manifestPolicy.second(), manifestSignature);
List<StickerPackFormUploadItem> stickers = new LinkedList<>();
for (int i=0;i<stickerCount;i++) {
String stickerKey = packLocation + "/full/" + i;
Pair<String, String> stickerPolicy = policyGenerator.createFor(now, stickerKey, 100155);
String stickerSignature = policySigner.getSignature(now, stickerPolicy.second());
stickers.add(new StickerPackFormUploadItem(i, stickerKey, stickerPolicy.first(), "private", "AWS4-HMAC-SHA256",
now.format(PostPolicyGenerator.AWS_DATE_TIME), stickerPolicy.second(), stickerSignature));
}
return new StickerPackFormUploadAttributes(packId, manifest, stickers);
}
private String generatePackId() {
byte[] object = new byte[16];
new SecureRandom().nextBytes(object);
return Hex.toStringCondensed(object);
}
}

View File

@ -0,0 +1,109 @@
package org.whispersystems.textsecuregcm.entities;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class StickerPackFormUploadAttributes {
@JsonProperty
private StickerPackFormUploadItem manifest;
@JsonProperty
private List<StickerPackFormUploadItem> stickers;
@JsonProperty
private String packId;
public StickerPackFormUploadAttributes() {}
public StickerPackFormUploadAttributes(String packId, StickerPackFormUploadItem manifest, List<StickerPackFormUploadItem> stickers) {
this.packId = packId;
this.manifest = manifest;
this.stickers = stickers;
}
public StickerPackFormUploadItem getManifest() {
return manifest;
}
public List<StickerPackFormUploadItem> getStickers() {
return stickers;
}
public String getPackId() {
return packId;
}
public static class StickerPackFormUploadItem {
@JsonProperty
private int id;
@JsonProperty
private String key;
@JsonProperty
private String credential;
@JsonProperty
private String acl;
@JsonProperty
private String algorithm;
@JsonProperty
private String date;
@JsonProperty
private String policy;
@JsonProperty
private String signature;
public StickerPackFormUploadItem() {}
public StickerPackFormUploadItem(int id, String key, String credential, String acl, String algorithm, String date, String policy, String signature) {
this.key = key;
this.credential = credential;
this.acl = acl;
this.algorithm = algorithm;
this.date = date;
this.policy = policy;
this.signature = signature;
this.id = id;
}
public String getKey() {
return key;
}
public String getCredential() {
return credential;
}
public String getAcl() {
return acl;
}
public String getAlgorithm() {
return algorithm;
}
public String getDate() {
return date;
}
public String getPolicy() {
return policy;
}
public String getSignature() {
return signature;
}
public int getId() {
return id;
}
}
}

View File

@ -42,6 +42,7 @@ public class RateLimiters {
private final RateLimiter turnLimiter;
private final RateLimiter profileLimiter;
private final RateLimiter stickerPackLimiter;
public RateLimiters(RateLimitsConfiguration config, ReplicatedJedisPool cacheClient) {
this.smsDestinationLimiter = new RateLimiter(cacheClient, "smsDestination",
@ -107,6 +108,10 @@ public class RateLimiters {
this.profileLimiter = new RateLimiter(cacheClient, "profile",
config.getProfile().getBucketSize(),
config.getProfile().getLeakRatePerMinute());
this.stickerPackLimiter = new RateLimiter(cacheClient, "stickerPack",
config.getStickerPack().getBucketSize(),
config.getStickerPack().getLeakRatePerMinute());
}
public RateLimiter getAllocateDeviceLimiter() {
@ -173,4 +178,8 @@ public class RateLimiters {
return profileLimiter;
}
public RateLimiter getStickerPackLimiter() {
return stickerPackLimiter;
}
}

View File

@ -22,7 +22,7 @@ public class PostPolicyGenerator {
this.awsAccessId = awsAccessId;
}
public Pair<String, String> createFor(ZonedDateTime now, String object) {
public Pair<String, String> createFor(ZonedDateTime now, String object, int maxSizeInBytes) {
try {
String expiration = now.plusMinutes(30).format(DateTimeFormatter.ISO_INSTANT);
String credentialDate = now.format(CREDENTIAL_DATE);
@ -35,7 +35,7 @@ public class PostPolicyGenerator {
" {\"key\": \"%s\"},\n" +
" {\"acl\": \"private\"},\n" +
" [\"starts-with\", \"$Content-Type\", \"\"],\n" +
" [\"content-length-range\", 1, 104857600],\n" +
" [\"content-length-range\", 1, " + maxSizeInBytes + "],\n" +
"\n" +
" {\"x-amz-credential\": \"%s\"},\n" +
" {\"x-amz-algorithm\": \"AWS4-HMAC-SHA256\"},\n" +

View File

@ -14,9 +14,11 @@ import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.net.MalformedURLException;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
@ -45,7 +47,7 @@ public class AttachmentControllerTest {
.build();
@Test
public void testV2Form() {
public void testV2Form() throws IOException {
AttachmentDescriptorV2 descriptor = resources.getJerseyTest()
.target("/v2/attachments/form/upload")
.request()
@ -68,6 +70,8 @@ public class AttachmentControllerTest {
assertThat(descriptor.getDate()).isNotBlank();
assertThat(descriptor.getPolicy()).isNotBlank();
assertThat(descriptor.getSignature()).isNotBlank();
assertThat(new String(Base64.decode(descriptor.getPolicy()))).contains("[\"content-length-range\", 1, 104857600]");
}
@Test

View File

@ -6,7 +6,7 @@ import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount;
import org.whispersystems.textsecuregcm.configuration.ProfilesConfiguration;
import org.whispersystems.textsecuregcm.configuration.CdnConfiguration;
import org.whispersystems.textsecuregcm.controllers.ProfileController;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.entities.Profile;
@ -30,7 +30,7 @@ public class ProfileControllerTest {
private static AccountsManager accountsManager = mock(AccountsManager.class );
private static RateLimiters rateLimiters = mock(RateLimiters.class );
private static RateLimiter rateLimiter = mock(RateLimiter.class );
private static ProfilesConfiguration configuration = mock(ProfilesConfiguration.class);
private static CdnConfiguration configuration = mock(CdnConfiguration.class);
static {
when(configuration.getAccessKey()).thenReturn("accessKey");
@ -80,7 +80,7 @@ public class ProfileControllerTest {
verify(accountsManager, times(1)).get(AuthHelper.VALID_NUMBER_TWO);
verify(rateLimiters, times(1)).getProfileLimiter();
verify(rateLimiter, times(1)).validate(AuthHelper.VALID_NUMBER);
verify(rateLimiter, times(1)).validate(eq(AuthHelper.VALID_NUMBER));
}
@Test

View File

@ -0,0 +1,96 @@
package org.whispersystems.textsecuregcm.tests.controllers;
import com.google.common.collect.ImmutableSet;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccount;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.controllers.StickerController;
import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.Base64;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import javax.ws.rs.core.Response;
import java.io.IOException;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit.ResourceTestRule;
import static org.assertj.core.api.Java6Assertions.assertThat;
import static org.mockito.Mockito.*;
public class StickerControllerTest {
private static RateLimiter rateLimiter = mock(RateLimiter.class );
private static RateLimiters rateLimiters = mock(RateLimiters.class);
@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 StickerController(rateLimiters, "foo", "bar", "us-east-1", "mybucket"))
.build();
@Before
public void setup() {
when(rateLimiters.getStickerPackLimiter()).thenReturn(rateLimiter);
}
@Test
public void testCreatePack() throws RateLimitExceededException, IOException {
StickerPackFormUploadAttributes attributes = resources.getJerseyTest()
.target("/v1/sticker/pack/form/10")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
.get(StickerPackFormUploadAttributes.class);
assertThat(attributes.getPackId()).isNotNull();
assertThat(attributes.getPackId().length()).isEqualTo(32);
assertThat(attributes.getManifest()).isNotNull();
assertThat(attributes.getManifest().getKey()).isEqualTo("stickers/" + attributes.getPackId() + "/manifest.proto");
assertThat(attributes.getManifest().getAcl()).isEqualTo("private");
assertThat(attributes.getManifest().getPolicy()).isNotEmpty();
assertThat(new String(Base64.decode(attributes.getManifest().getPolicy()))).contains("[\"content-length-range\", 1, 1024]");
assertThat(attributes.getManifest().getSignature()).isNotEmpty();
assertThat(attributes.getManifest().getAlgorithm()).isEqualTo("AWS4-HMAC-SHA256");
assertThat(attributes.getManifest().getCredential()).isNotEmpty();
assertThat(attributes.getManifest().getId()).isEqualTo(-1);
assertThat(attributes.getStickers().size()).isEqualTo(10);
for (int i=0;i<10;i++) {
assertThat(attributes.getStickers().get(i).getId()).isEqualTo(i);
assertThat(attributes.getStickers().get(i).getKey()).isEqualTo("stickers/" + attributes.getPackId() + "/full/" + i);
assertThat(attributes.getStickers().get(i).getAcl()).isEqualTo("private");
assertThat(attributes.getStickers().get(i).getPolicy()).isNotEmpty();
assertThat(new String(Base64.decode(attributes.getStickers().get(i).getPolicy()))).contains("[\"content-length-range\", 1, 100155]");
assertThat(attributes.getStickers().get(i).getSignature()).isNotEmpty();
assertThat(attributes.getStickers().get(i).getAlgorithm()).isEqualTo("AWS4-HMAC-SHA256");
assertThat(attributes.getStickers().get(i).getCredential()).isNotEmpty();
}
verify(rateLimiters, times(1)).getStickerPackLimiter();
verify(rateLimiter, times(1)).validate(eq(AuthHelper.VALID_NUMBER));
}
@Test
public void testCreateTooLargePack() throws Exception {
Response response = resources.getJerseyTest()
.target("/v1/sticker/pack/form/51")
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD))
.get();
assertThat(response.getStatus()).isEqualTo(400);
}
}