diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index 65b577f0d..574cf9801 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -11,6 +11,9 @@ directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789 svr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users svr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users +svr3.userAuthenticationTokenSharedSecret: cbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR3 to generate auth tokens for Signal users +svr3.userIdTokenSharedSecret: dbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR3 to generate auth identity tokens for Signal users + tus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= awsAttachments.accessKey: test diff --git a/service/config/sample.yml b/service/config/sample.yml index 60471258d..c068a9886 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -179,6 +179,34 @@ svr2: AAAAAAAAAAAAAAAAAAAA -----END CERTIFICATE----- +svr3: + uri: svr3.example.com + userAuthenticationTokenSharedSecret: secret://svr3.userAuthenticationTokenSharedSecret + userIdTokenSharedSecret: secret://svr3.userIdTokenSharedSecret + svrCaCertificates: + - | + -----BEGIN CERTIFICATE----- + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz + AAAAAAAAAAAAAAAAAAAA + -----END CERTIFICATE----- + messageCache: # Redis server configuration for message store cache persistDelayMinutes: 1 diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index b39c79922..30410d685 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -49,6 +49,7 @@ import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration; import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration; +import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration; import org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration; import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration; import org.whispersystems.textsecuregcm.configuration.StripeConfiguration; @@ -136,6 +137,10 @@ public class WhisperServerConfiguration extends Configuration { @Valid @JsonProperty private SecureValueRecovery2Configuration svr2; + @NotNull + @Valid + @JsonProperty + private SecureValueRecovery3Configuration svr3; @NotNull @Valid @@ -367,9 +372,13 @@ public class WhisperServerConfiguration extends Configuration { return metricsCluster; } + public SecureValueRecovery2Configuration getSvr2Configuration() { return svr2; } + public SecureValueRecovery3Configuration getSvr3Configuration() { + return svr3; + } public DirectoryV2Configuration getDirectoryV2Configuration() { return directoryV2; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 5a03c7518..e2d718000 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -116,6 +116,7 @@ import org.whispersystems.textsecuregcm.controllers.RegistrationController; import org.whispersystems.textsecuregcm.controllers.RemoteConfigController; import org.whispersystems.textsecuregcm.controllers.SecureStorageController; import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller; +import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery3Controller; import org.whispersystems.textsecuregcm.controllers.StickerController; import org.whispersystems.textsecuregcm.controllers.SubscriptionController; import org.whispersystems.textsecuregcm.controllers.VerificationController; @@ -496,7 +497,9 @@ public class WhisperServerService extends Application svrCaCertificates, + @NotNull @Valid CircuitBreakerConfiguration circuitBreaker, + @NotNull @Valid RetryConfiguration retry) { + + public SecureValueRecovery3Configuration { + if (circuitBreaker == null) { + circuitBreaker = new CircuitBreakerConfiguration(); + } + + if (retry == null) { + retry = new RetryConfiguration(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java index 7e1fb6409..7f3994cc1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java @@ -5,6 +5,7 @@ package org.whispersystems.textsecuregcm.controllers; +import com.google.common.annotations.VisibleForTesting; import io.dropwizard.auth.Auth; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -22,7 +23,6 @@ import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; -import org.jetbrains.annotations.TestOnly; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; @@ -45,7 +45,7 @@ public class SecureValueRecovery2Controller { return credentialsGenerator(cfg, Clock.systemUTC()); } - @TestOnly + @VisibleForTesting public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg, final Clock clock) { return ExternalServiceCredentialsGenerator .builder(cfg.userAuthenticationTokenSharedSecret()) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java new file mode 100644 index 000000000..b759fce22 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3Controller.java @@ -0,0 +1,129 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import com.google.common.annotations.VisibleForTesting; +import io.dropwizard.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector; +import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration; +import org.whispersystems.textsecuregcm.entities.AuthCheckRequest; +import org.whispersystems.textsecuregcm.entities.AuthCheckResponse; +import org.whispersystems.textsecuregcm.limits.RateLimitedByIp; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/v3/backup") +@Tag(name = "Secure Value Recovery") +public class SecureValueRecovery3Controller { + + private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30); + + public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery3Configuration cfg) { + return credentialsGenerator(cfg, Clock.systemUTC()); + } + + @VisibleForTesting + public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery3Configuration cfg, final Clock clock) { + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .withUserDerivationKey(cfg.userIdTokenSharedSecret().value()) + .prependUsername(false) + .withDerivedUsernameTruncateLength(16) + .withClock(clock) + .build(); + } + + private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator; + private final AccountsManager accountsManager; + + public SecureValueRecovery3Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator, + final AccountsManager accountsManager) { + this.backupServiceCredentialGenerator = backupServiceCredentialGenerator; + this.accountsManager = accountsManager; + } + + @GET + @Path("/auth") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Generate credentials for SVR3", + description = """ + Generate SVR3 service credentials. Generated credentials have an expiration time of 30 days + (however, the TTL is fully controlled by the server side and may change even for already generated credentials). + """ + ) + @ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") + public ExternalServiceCredentials getAuth(@Auth final AuthenticatedAccount auth) { + return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString()); + } + + + @POST + @Path("/auth/check") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK) + @Operation( + summary = "Check SVR3 credentials", + description = """ + Over time, clients may wind up with multiple sets of SVR3 authentication credentials in cloud storage. + To determine which set is most current and should be used to communicate with SVR3 to retrieve a master key + (from which a registration recovery password can be derived), clients should call this endpoint + with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR3. + """ + ) + @ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "422", description = "Provided list of SVR3 credentials could not be parsed") + @ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`") + public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) { + final List credentials = ExternalServiceCredentialsSelector.check( + request.passwords(), + backupServiceCredentialGenerator, + MAX_AGE_SECONDS); + + // the username associated with the provided number + final Optional matchingUsername = accountsManager + .getByE164(request.number()) + .map(Account::getUuid) + .map(backupServiceCredentialGenerator::generateForUuid) + .map(ExternalServiceCredentials::username); + + return new AuthCheckResponse(credentials.stream().collect(Collectors.toMap( + ExternalServiceCredentialsSelector.CredentialInfo::token, + info -> { + if (!info.valid()) { + return AuthCheckResponse.Result.INVALID; + } + final String username = info.credentials().username(); + // does this credential match the account id for the e164 provided in the request? + boolean match = matchingUsername.filter(username::equals).isPresent(); + return match ? AuthCheckResponse.Result.MATCH : AuthCheckResponse.Result.NO_MATCH; + } + ))); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java new file mode 100644 index 000000000..0204f6e51 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery3ControllerTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + + +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.extension.ExtendWith; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration; +import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery3Configuration; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.MutableClock; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +import static org.mockito.Mockito.mock; +import static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes; + +@ExtendWith(DropwizardExtensionsSupport.class) +public class SecureValueRecovery3ControllerTest extends SecureValueRecoveryControllerBaseTest { + + private static final SecureValueRecovery3Configuration CFG = new SecureValueRecovery3Configuration( + "", + randomSecretBytes(32), + randomSecretBytes(32), + null, + null, + null + ); + + private static final MutableClock CLOCK = new MutableClock(); + + private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR = + SecureValueRecovery3Controller.credentialsGenerator(CFG, CLOCK); + + private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class); + private static final SecureValueRecovery3Controller CONTROLLER = + new SecureValueRecovery3Controller(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER); + + private static final ResourceExtension RESOURCES = ResourceExtension.builder() + .addProvider(AuthHelper.getAuthFilter()) + .setMapper(SystemMapper.jsonMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(CONTROLLER) + .build(); + + protected SecureValueRecovery3ControllerTest() { + super("/v3", ACCOUNTS_MANAGER, CLOCK, RESOURCES, CREDENTIAL_GENERATOR); + } +}