diff --git a/service/config/sample.yml b/service/config/sample.yml index 4ddbfd9a0..51c0d9dac 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -319,6 +319,10 @@ paymentsService: # list of symbols for supported currencies - MOB +artService: + userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret not shared with any external service, but used in ArtController + userAuthenticationTokenUserIdSecret: 00000f # hex-encoded secret to obscure user phone numbers from Sticker Creator + badges: badges: - id: TEST diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 787aec628..a0ca5710e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2021 Signal Messenger, LLC + * Copyright 2013 Signal Messenger, LLC * SPDX-License-Identifier: AGPL-3.0-only */ package org.whispersystems.textsecuregcm; @@ -33,6 +33,7 @@ import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; +import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration; @@ -214,6 +215,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private PaymentsServiceConfiguration paymentsService; + @Valid + @NotNull + @JsonProperty + private ArtServiceConfiguration artService; + @Valid @NotNull @JsonProperty @@ -405,6 +411,10 @@ public class WhisperServerConfiguration extends Configuration { return paymentsService; } + public ArtServiceConfiguration getArtServiceConfiguration() { + return artService; + } + public ZkConfig getZkConfig() { return zkConfig; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index cdc3d7f10..e6eae4ee8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -106,6 +106,7 @@ 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.ArtController; import org.whispersystems.textsecuregcm.controllers.SubscriptionController; import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController; import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient; @@ -465,6 +466,10 @@ public class WhisperServerService extends Application commonControllers = Lists.newArrayList( + new ArtController(rateLimiters, artCredentialsGenerator), new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()), new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()), new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialGenerator.java index b46b9643a..0ca78f2b9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialGenerator.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialGenerator.java @@ -9,9 +9,9 @@ import com.google.common.annotations.VisibleForTesting; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.time.Clock; +import java.util.HexFormat; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import org.apache.commons.codec.binary.Hex; import org.whispersystems.textsecuregcm.util.Util; public class ExternalServiceCredentialGenerator { @@ -20,33 +20,50 @@ public class ExternalServiceCredentialGenerator { private final byte[] userIdKey; private final boolean usernameDerivation; private final boolean prependUsername; + private final boolean truncateKey; private final Clock clock; public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey) { - this(key, userIdKey, true, true); + this(key, userIdKey, true, true, true); } public ExternalServiceCredentialGenerator(byte[] key, boolean prependUsername) { - this(key, new byte[0], false, prependUsername); + this(key, prependUsername, true); + } + + public ExternalServiceCredentialGenerator(byte[] key, boolean prependUsername, boolean truncateKey) { + this(key, new byte[0], false, prependUsername, truncateKey); } @VisibleForTesting public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation) { - this(key, userIdKey, usernameDerivation, true); + this(key, userIdKey, usernameDerivation, true, true); } public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation, boolean prependUsername) { - this(key, userIdKey, usernameDerivation, prependUsername, Clock.systemUTC()); + this(key, userIdKey, usernameDerivation, prependUsername, true, Clock.systemUTC()); + } + + public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation, + boolean prependUsername, boolean truncateKey) { + this(key, userIdKey, usernameDerivation, prependUsername, truncateKey, Clock.systemUTC()); } @VisibleForTesting public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation, boolean prependUsername, Clock clock) { + this(key, userIdKey, usernameDerivation, prependUsername, true, clock); + } + + @VisibleForTesting + public ExternalServiceCredentialGenerator(byte[] key, byte[] userIdKey, boolean usernameDerivation, + boolean prependUsername, boolean truncateKey, Clock clock) { this.key = key; this.userIdKey = userIdKey; this.usernameDerivation = usernameDerivation; this.prependUsername = prependUsername; + this.truncateKey = truncateKey; this.clock = clock; } @@ -55,14 +72,17 @@ public class ExternalServiceCredentialGenerator { String username = getUserId(identity, mac, usernameDerivation); long currentTimeSeconds = clock.millis() / 1000; String prefix = username + ":" + currentTimeSeconds; - String output = Hex.encodeHexString(Util.truncate(getHmac(key, prefix.getBytes(), mac), 10)); + byte[] prefixMac = getHmac(key, prefix.getBytes(), mac); + final HexFormat hex = HexFormat.of(); + String output = hex.formatHex(truncateKey ? Util.truncate(prefixMac, 10) : prefixMac); String token = (prependUsername ? prefix : currentTimeSeconds) + ":" + output; return new ExternalServiceCredentials(username, token); } private String getUserId(String number, Mac mac, boolean usernameDerivation) { - if (usernameDerivation) return Hex.encodeHexString(Util.truncate(getHmac(userIdKey, number.getBytes(), mac), 10)); + final HexFormat hex = HexFormat.of(); + if (usernameDerivation) return hex.formatHex(Util.truncate(getHmac(userIdKey, number.getBytes(), mac), 10)); else return number; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ArtServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ArtServiceConfiguration.java new file mode 100644 index 000000000..d14cbda86 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ArtServiceConfiguration.java @@ -0,0 +1,41 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; + +import java.time.Duration; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +public class ArtServiceConfiguration { + + @NotEmpty + @JsonProperty + private String userAuthenticationTokenSharedSecret; + + @NotEmpty + @JsonProperty + private String userAuthenticationTokenUserIdSecret; + + @JsonProperty + @NotNull + private Duration tokenExpiration = Duration.ofDays(1); + + public byte[] getUserAuthenticationTokenSharedSecret() throws DecoderException { + return Hex.decodeHex(userAuthenticationTokenSharedSecret.toCharArray()); + } + + public byte[] getUserAuthenticationTokenUserIdSecret() throws DecoderException { + return Hex.decodeHex(userAuthenticationTokenUserIdSecret.toCharArray()); + } + + public Duration getTokenExpiration() { + return tokenExpiration; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java index 4b836a0ed..a8a4bcfac 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java @@ -56,6 +56,9 @@ public class RateLimitsConfiguration { @JsonProperty private RateLimitConfiguration stickerPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0)); + @JsonProperty + private RateLimitConfiguration artPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0)); + @JsonProperty private RateLimitConfiguration usernameLookup = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); @@ -135,6 +138,10 @@ public class RateLimitsConfiguration { return stickerPack; } + public RateLimitConfiguration getArtPack() { + return artPack; + } + public RateLimitConfiguration getUsernameLookup() { return usernameLookup; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java new file mode 100644 index 000000000..8ae90c7a1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import io.dropwizard.auth.Auth; +import java.util.UUID; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.limits.RateLimiters; + +@Path("/v1/art") +public class ArtController { + private final ExternalServiceCredentialGenerator artServiceCredentialGenerator; + private final RateLimiters rateLimiters; + + public ArtController(RateLimiters rateLimiters, + ExternalServiceCredentialGenerator artServiceCredentialGenerator) { + this.artServiceCredentialGenerator = artServiceCredentialGenerator; + this.rateLimiters = rateLimiters; + } + + @Timed + @GET + @Path("/auth") + @Produces(MediaType.APPLICATION_JSON) + public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) + throws RateLimitExceededException { + final UUID uuid = auth.getAccount().getUuid(); + rateLimiters.getArtPackLimiter().validate(uuid); + return artServiceCredentialGenerator.generateFor(uuid.toString()); + } +} 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 4886241e9..4485bd205 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -29,6 +29,8 @@ public class RateLimiters { private final RateLimiter profileLimiter; private final RateLimiter stickerPackLimiter; + + private final RateLimiter artPackLimiter; private final RateLimiter usernameLookupLimiter; private final RateLimiter usernameSetLimiter; @@ -99,6 +101,10 @@ public class RateLimiters { config.getStickerPack().getBucketSize(), config.getStickerPack().getLeakRatePerMinute()); + this.artPackLimiter = new RateLimiter(cacheCluster, "artPack", + config.getArtPack().getBucketSize(), + config.getArtPack().getLeakRatePerMinute()); + this.usernameLookupLimiter = new RateLimiter(cacheCluster, "usernameLookup", config.getUsernameLookup().getBucketSize(), config.getUsernameLookup().getLeakRatePerMinute()); @@ -181,6 +187,10 @@ public class RateLimiters { return stickerPackLimiter; } + public RateLimiter getArtPackLimiter() { + return artPackLimiter; + } + public RateLimiter getUsernameLookupLimiter() { return usernameLookupLimiter; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ArtControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ArtControllerTest.java new file mode 100644 index 000000000..0f8106ec1 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ArtControllerTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.tests.controllers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableSet; +import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.controllers.ArtController; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +@ExtendWith(DropwizardExtensionsSupport.class) +class ArtControllerTest { + + private static final ExternalServiceCredentialGenerator artCredentialGenerator = new ExternalServiceCredentialGenerator( + new byte[32], new byte[32], true, false, false); + private static final RateLimiter rateLimiter = mock(RateLimiter.class); + private static final RateLimiters rateLimiters = mock(RateLimiters.class); + + private static final ResourceExtension resources = ResourceExtension.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( + ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) + .setMapper(SystemMapper.getMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new ArtController(rateLimiters, artCredentialGenerator)) + .build(); + + @Test + void testGetAuthToken() { + when(rateLimiters.getArtPackLimiter()).thenReturn(rateLimiter); + + ExternalServiceCredentials token = + resources.getJerseyTest() + .target("/v1/art/auth") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .get(ExternalServiceCredentials.class); + + assertThat(token.getPassword()).isNotEmpty(); + assertThat(token.getUsername()).isNotEmpty(); + } +}