diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 5f2084c37..0826a9a59 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -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() { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 323ef521e..b983e96b0 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -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 accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter (); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter(); @@ -266,6 +268,7 @@ public class WhisperServerService extends Application policy = policyGenerator.createFor(now, String.valueOf(objectName)); + Pair policy = policyGenerator.createFor(now, String.valueOf(objectName), 100 * 1024 * 1024); String signature = policySigner.getSignature(now, policy.second()); return new AttachmentDescriptorV2(attachmentId, objectName, policy.first(), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java index d5c209667..582ebcbf8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java @@ -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 policy = policyGenerator.createFor(now, objectName); + Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024); String signature = policySigner.getSignature(now, policy.second()); if (previousAvatar != null && previousAvatar.startsWith("profiles/")) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java new file mode 100644 index 000000000..e532b014b --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java @@ -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 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 stickers = new LinkedList<>(); + + for (int i=0;i 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); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java new file mode 100644 index 000000000..76121d85e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java @@ -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 stickers; + + @JsonProperty + private String packId; + + public StickerPackFormUploadAttributes() {} + + public StickerPackFormUploadAttributes(String packId, StickerPackFormUploadItem manifest, List stickers) { + this.packId = packId; + this.manifest = manifest; + this.stickers = stickers; + } + + public StickerPackFormUploadItem getManifest() { + return manifest; + } + + public List 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; + } + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index 295532801..8c785767b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -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; + } + } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java index 8b5468359..ec45e42c8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java @@ -22,7 +22,7 @@ public class PostPolicyGenerator { this.awsAccessId = awsAccessId; } - public Pair createFor(ZonedDateTime now, String object) { + public Pair 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" + 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 841018a69..e6a27ed3a 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 @@ -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 diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java index 890215ae5..fab22bbd8 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ProfileControllerTest.java @@ -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 diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/StickerControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/StickerControllerTest.java new file mode 100644 index 000000000..bec842017 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/StickerControllerTest.java @@ -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); + + } + +}