diff --git a/pom.xml b/pom.xml index 96a5cead2..9f81ba92f 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ 2.9.0 2.0.32 1.1.13 + 1.46.0 2.9.0 30.1.1-jre 2.13.3 @@ -94,6 +95,29 @@ pom import + + io.grpc + grpc-netty-shaded + ${grpc.version} + runtime + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + + org.apache.tomcat + annotations-api + 6.0.53 + provided + io.netty netty-bom @@ -368,13 +392,16 @@ protobuf-maven-plugin 0.6.1 - com.google.protobuf:protoc:3.18.0:exe:${os.detected.classifier} - true + false + com.google.protobuf:protoc:3.21.1:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} compile + compile-custom test-compile diff --git a/service/config/sample.yml b/service/config/sample.yml index b851b80ec..84f6031ea 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -374,3 +374,29 @@ gift: currencies: # ISO 4217 currency codes and amounts in those currencies xts: '2' + +registrationService: + host: registration.example.com + apiKey: EXAMPLE + registrationCaCertificate: | # Registration service TLS certificate trust root + -----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----- diff --git a/service/pom.xml b/service/pom.xml index 9efba705a..305e1f53b 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -229,6 +229,26 @@ resilience4j-retry + + io.grpc + grpc-netty-shaded + runtime + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + + org.apache.tomcat + annotations-api + provided + + io.micrometer micrometer-core diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index e95accaa0..0a7bc4a32 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -37,6 +37,7 @@ import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration; import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; +import org.whispersystems.textsecuregcm.configuration.RegistrationServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration; import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration; import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; @@ -263,6 +264,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private AbusiveMessageFilterConfiguration abusiveMessageFilter; + @Valid + @NotNull + @JsonProperty + private RegistrationServiceConfiguration registrationService; + public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() { return adminEventLoggingConfiguration; } @@ -444,4 +450,8 @@ public class WhisperServerConfiguration extends Configuration { public UsernameConfiguration getUsername() { return username; } + + public RegistrationServiceConfiguration getRegistrationServiceConfiguration() { + return registrationService; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 9fb6b657e..bfe524c20 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -158,6 +158,7 @@ import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger; import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; +import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.s3.PolicySigner; import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; @@ -410,6 +411,11 @@ public class WhisperServerService extends Application commonControllers = Lists.newArrayList( diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java index 6a0d6072b..e04c62ca3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java @@ -5,60 +5,19 @@ package org.whispersystems.textsecuregcm.auth; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; import java.security.MessageDigest; import java.time.Duration; -import java.util.Optional; import javax.annotation.Nullable; import org.whispersystems.textsecuregcm.util.Util; -public class StoredVerificationCode { - - @JsonProperty - private final String code; - - @JsonProperty - private final long timestamp; - - @JsonProperty - private final String pushCode; - - @JsonProperty - @Nullable - private final String twilioVerificationSid; +public record StoredVerificationCode(String code, + long timestamp, + String pushCode, + @Nullable String twilioVerificationSid, + @Nullable byte[] sessionId) { public static final Duration EXPIRATION = Duration.ofMinutes(10); - @JsonCreator - public StoredVerificationCode( - @JsonProperty("code") final String code, - @JsonProperty("timestamp") final long timestamp, - @JsonProperty("pushCode") final String pushCode, - @JsonProperty("twilioVerificationSid") @Nullable final String twilioVerificationSid) { - - this.code = code; - this.timestamp = timestamp; - this.pushCode = pushCode; - this.twilioVerificationSid = twilioVerificationSid; - } - - public String getCode() { - return code; - } - - public long getTimestamp() { - return timestamp; - } - - public String getPushCode() { - return pushCode; - } - - public Optional getTwilioVerificationSid() { - return Optional.ofNullable(twilioVerificationSid); - } - public boolean isValid(String theirCodeString) { if (Util.isEmpty(code) || Util.isEmpty(theirCodeString)) { return false; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java new file mode 100644 index 000000000..fdd38fb91 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java @@ -0,0 +1,49 @@ +package org.whispersystems.textsecuregcm.configuration; + +import javax.validation.constraints.NotBlank; + +public class RegistrationServiceConfiguration { + + @NotBlank + private String host; + + private int port = 443; + + @NotBlank + private String apiKey; + + @NotBlank + private String registrationCaCertificate; + + public String getHost() { + return host; + } + + public void setHost(final String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(final int port) { + this.port = port; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(final String apiKey) { + this.apiKey = apiKey; + } + + public String getRegistrationCaCertificate() { + return registrationCaCertificate; + } + + public void setRegistrationCaCertificate(final String registrationCaCertificate) { + this.registrationCaCertificate = registrationCaCertificate; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 5ce542ff8..96de80589 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -11,6 +11,9 @@ import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.SharedMetricRegistries; import com.codahale.metrics.annotation.Timed; import com.google.common.annotations.VisibleForTesting; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; import io.dropwizard.auth.Auth; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; @@ -80,12 +83,16 @@ import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse; import org.whispersystems.textsecuregcm.entities.StaleDevices; import org.whispersystems.textsecuregcm.entities.UsernameRequest; import org.whispersystems.textsecuregcm.entities.UsernameResponse; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; import org.whispersystems.textsecuregcm.push.PushNotification; import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; +import org.whispersystems.textsecuregcm.registration.ClientType; +import org.whispersystems.textsecuregcm.registration.MessageTransport; +import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.TwilioVerifyExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.storage.AbusiveHostRules; @@ -145,14 +152,13 @@ public class AccountController { private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport"; private static final String SCORE_TAG_NAME = "score"; - private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify"; - private final StoredVerificationCodeManager pendingAccounts; private final AccountsManager accounts; private final AbusiveHostRules abusiveHostRules; private final RateLimiters rateLimiters; private final SmsSender smsSender; + private final RegistrationServiceClient registrationServiceClient; private final DynamicConfigurationManager dynamicConfigurationManager; private final TurnTokenGenerator turnTokenGenerator; private final Map testDevices; @@ -161,13 +167,21 @@ public class AccountController { private final ExternalServiceCredentialGenerator backupServiceCredentialGenerator; private final TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager; + private final ExperimentEnrollmentManager experimentEnrollmentManager; private final ChangeNumberManager changeNumberManager; + @VisibleForTesting + static final String REGISTRATION_SERVICE_EXPERIMENT_NAME = "registration-service"; + + @VisibleForTesting + static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15); + public AccountController(StoredVerificationCodeManager pendingAccounts, AccountsManager accounts, AbusiveHostRules abusiveHostRules, RateLimiters rateLimiters, SmsSender smsSenderFactory, + RegistrationServiceClient registrationServiceClient, DynamicConfigurationManager dynamicConfigurationManager, TurnTokenGenerator turnTokenGenerator, Map testDevices, @@ -175,13 +189,15 @@ public class AccountController { PushNotificationManager pushNotificationManager, TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager, ChangeNumberManager changeNumberManager, - ExternalServiceCredentialGenerator backupServiceCredentialGenerator) + ExternalServiceCredentialGenerator backupServiceCredentialGenerator, + final ExperimentEnrollmentManager experimentEnrollmentManager) { this.pendingAccounts = pendingAccounts; this.accounts = accounts; this.abusiveHostRules = abusiveHostRules; this.rateLimiters = rateLimiters; this.smsSender = smsSenderFactory; + this.registrationServiceClient = registrationServiceClient; this.dynamicConfigurationManager = dynamicConfigurationManager; this.testDevices = testDevices; this.turnTokenGenerator = turnTokenGenerator; @@ -190,6 +206,7 @@ public class AccountController { this.verifyExperimentEnrollmentManager = verifyExperimentEnrollmentManager; this.backupServiceCredentialGenerator = backupServiceCredentialGenerator; this.changeNumberManager = changeNumberManager; + this.experimentEnrollmentManager = experimentEnrollmentManager; } @Timed @@ -210,14 +227,12 @@ public class AccountController { Util.requireNormalizedNumber(number); - String pushChallenge = generatePushChallenge(); - StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null, - System.currentTimeMillis(), - pushChallenge, - null); + String pushChallenge = generatePushChallenge(); + StoredVerificationCode storedVerificationCode = + new StoredVerificationCode(null, System.currentTimeMillis(), pushChallenge, null, null); pendingAccounts.store(number, storedVerificationCode); - pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.getPushCode()); + pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.pushCode()); return Response.ok().build(); } @@ -239,9 +254,8 @@ public class AccountController { Util.requireNormalizedNumber(number); - String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow(); - - Optional storedChallenge = pendingAccounts.getCodeForNumber(number); + final String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow(); + final Optional maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number); final String countryCode = Util.getCountryCode(number); final String region = Util.getRegion(number); @@ -260,7 +274,8 @@ public class AccountController { Tag.of(SCORE_TAG_NAME, result.score()))) .increment()); - boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, storedChallenge); + final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode); + if (pushChallenge.isPresent() && !pushChallengeMatch) { throw new WebApplicationException(Response.status(403).build()); } @@ -289,11 +304,46 @@ public class AccountController { default -> throw new WebApplicationException(Response.status(422).build()); } - VerificationCode verificationCode = generateVerificationCode(number); - StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(), + if (experimentEnrollmentManager.isEnrolled(number, REGISTRATION_SERVICE_EXPERIMENT_NAME)) { + sendVerificationCodeViaRegistrationService(number, + maybeStoredVerificationCode, + acceptLanguage, + client, + transport); + } else { + sendVerificationCodeViaTwilioSender(number, + maybeStoredVerificationCode, + acceptLanguage, + userAgent, + client, + transport, + assessmentResult); + } + + Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of( + UserAgentTagUtil.getPlatformTag(userAgent), + Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), + Tag.of(REGION_TAG_NAME, Util.getRegion(number)), + Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport))) + .increment(); + + return Response.ok().build(); + } + + private void sendVerificationCodeViaTwilioSender(final String number, + final Optional maybeStoredVerificationCode, + final Optional acceptLanguage, + final String userAgent, + final Optional client, + final String transport, + final Optional assessmentResult) { + final VerificationCode verificationCode = generateVerificationCode(number); + + final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), - storedChallenge.map(StoredVerificationCode::getPushCode).orElse(null), - storedChallenge.flatMap(StoredVerificationCode::getTwilioVerificationSid).orElse(null)); + maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null), + maybeStoredVerificationCode.map(StoredVerificationCode::twilioVerificationSid).orElse(null), + maybeStoredVerificationCode.map(StoredVerificationCode::sessionId).orElse(null)); pendingAccounts.store(number, storedVerificationCode); @@ -344,7 +394,11 @@ public class AccountController { logger.warn("Error with Twilio Verify", throwable); return; } + if (enrolledInVerifyExperiment && maybeVerificationSid.isEmpty() && assessmentResult.isPresent()) { + final String countryCode = Util.getCountryCode(number); + final String region = Util.getRegion(number); + Metrics.counter(TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME, Tags.of( Tag.of(COUNTRY_CODE_TAG_NAME, countryCode), Tag.of(REGION_TAG_NAME, region), @@ -352,28 +406,61 @@ public class AccountController { Tag.of(SCORE_TAG_NAME, assessmentResult.get().score()))) .increment(); } + maybeVerificationSid.ifPresent(twilioVerificationSid -> { StoredVerificationCode storedVerificationCodeWithVerificationSid = new StoredVerificationCode( - storedVerificationCode.getCode(), - storedVerificationCode.getTimestamp(), - storedVerificationCode.getPushCode(), - twilioVerificationSid); + storedVerificationCode.code(), + storedVerificationCode.timestamp(), + storedVerificationCode.pushCode(), + twilioVerificationSid, + storedVerificationCode.sessionId()); pendingAccounts.store(number, storedVerificationCodeWithVerificationSid); }); }); + } - // TODO Remove this meter when external dependencies have been resolved - metricRegistry.meter(name(AccountController.class, "create", Util.getCountryCode(number))).mark(); + private void sendVerificationCodeViaRegistrationService(final String number, + final Optional maybeStoredVerificationCode, + final Optional acceptLanguage, + final Optional client, + final String transport) { - Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), - Tag.of(REGION_TAG_NAME, Util.getRegion(number)), - Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport), - Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(enrolledInVerifyExperiment)))) - .increment(); + final Phonenumber.PhoneNumber phoneNumber; - return Response.ok().build(); + try { + phoneNumber = PhoneNumberUtil.getInstance().parse(number, null); + } catch (final NumberParseException e) { + throw new WebApplicationException(Response.status(422).build()); + } + + final MessageTransport messageTransport = switch (transport) { + case "sms" -> MessageTransport.SMS; + case "voice" -> MessageTransport.VOICE; + default -> throw new WebApplicationException(Response.status(422).build()); + }; + + final ClientType clientType = client.map(clientTypeString -> { + if ("ios".equalsIgnoreCase(clientTypeString)) { + return ClientType.IOS; + } else if ("android-2021-03".equalsIgnoreCase(clientTypeString)) { + return ClientType.ANDROID_WITH_FCM; + } else if (StringUtils.startsWithIgnoreCase(clientTypeString, "android")) { + return ClientType.ANDROID_WITHOUT_FCM; + } else { + return ClientType.UNKNOWN; + } + }).orElse(ClientType.UNKNOWN); + + final byte[] sessionId = registrationServiceClient.sendRegistrationCode(phoneNumber, + messageTransport, clientType, acceptLanguage.orElse(null), REGISTRATION_RPC_TIMEOUT).join(); + + final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null, + System.currentTimeMillis(), + maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null), + null, + sessionId); + + pendingAccounts.store(number, storedVerificationCode); } @Timed @@ -397,13 +484,20 @@ public class AccountController { // Note that successful verification depends on being able to find a stored verification code for the given number. // We check that numbers are normalized before we store verification codes, and so don't need to re-assert // normalization here. - Optional storedVerificationCode = pendingAccounts.getCodeForNumber(number); + final Optional maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number); - if (storedVerificationCode.isEmpty() || !storedVerificationCode.get().isValid(verificationCode)) { + final boolean codeVerified = maybeStoredVerificationCode.map(storedVerificationCode -> + storedVerificationCode.sessionId() != null ? + registrationServiceClient.checkVerificationCode(storedVerificationCode.sessionId(), + verificationCode, REGISTRATION_RPC_TIMEOUT).join() : + storedVerificationCode.isValid(verificationCode)) + .orElse(false); + + if (!codeVerified) { throw new WebApplicationException(Response.status(403).build()); } - storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid) + maybeStoredVerificationCode.map(StoredVerificationCode::twilioVerificationSid) .ifPresent( verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "registration")); @@ -427,8 +521,7 @@ public class AccountController { Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), Tag.of(REGION_TAG_NAME, Util.getRegion(number)), - Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)), - Tag.of(VERIFY_EXPERIMENT_TAG_NAME, String.valueOf(storedVerificationCode.get().getTwilioVerificationSid().isPresent())))) + Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)))) .increment(); return new AccountIdentityResponse(account.getUuid(), @@ -459,14 +552,13 @@ public class AccountController { rateLimiters.getVerifyLimiter().validate(number); - final Optional storedVerificationCode = - pendingAccounts.getCodeForNumber(number); + final Optional storedVerificationCode = pendingAccounts.getCodeForNumber(number); if (storedVerificationCode.isEmpty() || !storedVerificationCode.get().isValid(request.code())) { throw new ForbiddenException(); } - storedVerificationCode.flatMap(StoredVerificationCode::getTwilioVerificationSid) + storedVerificationCode.map(StoredVerificationCode::twilioVerificationSid) .ifPresent( verificationSid -> smsSender.reportVerificationSucceeded(verificationSid, userAgent, "changeNumber")); @@ -842,7 +934,7 @@ public class AccountController { final Optional storedVerificationCode) { final String countryCode = Util.getCountryCode(number); final String region = Util.getRegion(number); - Optional storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::getPushCode); + Optional storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::pushCode); boolean match = Optionals.zipWith(pushChallenge, storedPushChallenge, String::equals).orElse(false); Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME, COUNTRY_CODE_TAG_NAME, countryCode, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java index d8fd1691e..3e81cb357 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java @@ -132,11 +132,9 @@ public class DeviceController { throw new WebApplicationException(Response.Status.UNAUTHORIZED); } - VerificationCode verificationCode = generateVerificationCode(); - StoredVerificationCode storedVerificationCode = new StoredVerificationCode(verificationCode.getVerificationCode(), - System.currentTimeMillis(), - null, - null); + VerificationCode verificationCode = generateVerificationCode(); + StoredVerificationCode storedVerificationCode = + new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), null, null, null); pendingDevices.store(account.getNumber(), storedVerificationCode); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/ApiKeyCallCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/ApiKeyCallCredentials.java new file mode 100644 index 000000000..f47ce01c4 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/ApiKeyCallCredentials.java @@ -0,0 +1,32 @@ +package org.whispersystems.textsecuregcm.registration; + +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import java.util.concurrent.Executor; + +class ApiKeyCallCredentials extends CallCredentials { + + private final String apiKey; + + private static final Metadata.Key API_KEY_METADATA_KEY = + Metadata.Key.of("x-signal-api-key", Metadata.ASCII_STRING_MARSHALLER); + + ApiKeyCallCredentials(final String apiKey) { + this.apiKey = apiKey; + } + + @Override + public void applyRequestMetadata(final RequestInfo requestInfo, + final Executor appExecutor, + final MetadataApplier applier) { + + final Metadata metadata = new Metadata(); + metadata.put(API_KEY_METADATA_KEY, apiKey); + + applier.apply(metadata); + } + + @Override + public void thisUsesUnstableApi() { + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java new file mode 100644 index 000000000..7a5a9c546 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.registration; + +public enum ClientType { + IOS, + ANDROID_WITH_FCM, + ANDROID_WITHOUT_FCM, + UNKNOWN +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java new file mode 100644 index 000000000..f45f0f0e3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java @@ -0,0 +1,14 @@ +/* + * Copyright 2022 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.registration; + +/** + * A message transport is a medium via which verification codes can be delivered to a destination phone. + */ +public enum MessageTransport { + SMS, + VOICE +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java new file mode 100644 index 000000000..a75353596 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java @@ -0,0 +1,138 @@ +package org.whispersystems.textsecuregcm.registration; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; +import com.google.protobuf.ByteString; +import io.dropwizard.lifecycle.Managed; +import io.grpc.ChannelCredentials; +import io.grpc.Deadline; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.TlsChannelCredentials; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.StringUtils; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.signal.registration.rpc.CheckVerificationCodeRequest; +import org.signal.registration.rpc.CheckVerificationCodeResponse; +import org.signal.registration.rpc.RegistrationServiceGrpc; +import org.signal.registration.rpc.SendVerificationCodeRequest; + +public class RegistrationServiceClient implements Managed { + + private final ManagedChannel channel; + private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub; + private final Executor callbackExecutor; + + public RegistrationServiceClient(final String host, + final int port, + final String apiKey, + final String caCertificatePem, + final Executor callbackExecutor) throws IOException { + + try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream(caCertificatePem.getBytes(StandardCharsets.UTF_8))) { + final ChannelCredentials tlsChannelCredentials = TlsChannelCredentials.newBuilder() + .trustManager(certificateInputStream) + .build(); + + this.channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials).build(); + } + + this.stub = RegistrationServiceGrpc.newFutureStub(channel) + .withCallCredentials(new ApiKeyCallCredentials(apiKey)); + + this.callbackExecutor = callbackExecutor; + } + + public CompletableFuture sendRegistrationCode(final Phonenumber.PhoneNumber phoneNumber, + final MessageTransport messageTransport, + final ClientType clientType, + @Nullable final String acceptLanguage, + final Duration timeout) { + + final long e164 = Long.parseLong( + PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1)); + + final SendVerificationCodeRequest.Builder requestBuilder = SendVerificationCodeRequest.newBuilder() + .setE164(e164) + .setTransport(getRpcMessageTransport(messageTransport)) + .setClientType(getRpcClientType(clientType)); + + if (StringUtils.isNotBlank(acceptLanguage)) { + requestBuilder.setAcceptLanguage(acceptLanguage); + } + + return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + .sendVerificationCode(requestBuilder.build())) + .thenApply(response -> response.getSessionId().toByteArray()); + } + + public CompletableFuture checkVerificationCode(final byte[] sessionId, + final String verificationCode, + final Duration timeout) { + + return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) + .checkVerificationCode(CheckVerificationCodeRequest.newBuilder() + .setSessionId(ByteString.copyFrom(sessionId)) + .setVerificationCode(verificationCode) + .build())) + .thenApply(CheckVerificationCodeResponse::getVerified); + } + + private static Deadline toDeadline(final Duration timeout) { + return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private static org.signal.registration.rpc.ClientType getRpcClientType(final ClientType clientType) { + return switch (clientType) { + case IOS -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_IOS; + case ANDROID_WITH_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITH_FCM; + case ANDROID_WITHOUT_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITHOUT_FCM; + case UNKNOWN -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_UNSPECIFIED; + }; + } + + private static org.signal.registration.rpc.MessageTransport getRpcMessageTransport(final MessageTransport transport) { + return switch (transport) { + case SMS -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_SMS; + case VOICE -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_VOICE; + }; + } + + private CompletableFuture toCompletableFuture(final ListenableFuture listenableFuture) { + final CompletableFuture completableFuture = new CompletableFuture<>(); + + Futures.addCallback(listenableFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable final T result) { + completableFuture.complete(result); + } + + @Override + public void onFailure(final Throwable throwable) { + completableFuture.completeExceptionally(throwable); + } + }, callbackExecutor); + + return completableFuture; + } + + @Override + public void start() throws Exception { + } + + @Override + public void stop() throws Exception { + if (channel != null) { + channel.shutdown(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java index 78dc9a839..9f80d5f83 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java @@ -70,7 +70,7 @@ public class VerificationCodeStore { } private long getExpirationTimestamp(final StoredVerificationCode storedVerificationCode) { - return Instant.ofEpochMilli(storedVerificationCode.getTimestamp()).plus(StoredVerificationCode.EXPIRATION).getEpochSecond(); + return Instant.ofEpochMilli(storedVerificationCode.timestamp()).plus(StoredVerificationCode.EXPIRATION).getEpochSecond(); } public Optional findForNumber(final String number) { diff --git a/service/src/main/proto/RegistrationService.proto b/service/src/main/proto/RegistrationService.proto new file mode 100644 index 000000000..ac49978f2 --- /dev/null +++ b/service/src/main/proto/RegistrationService.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.registration.rpc; + +service RegistrationService { + /** + * Sends a verification code to a destination phone number and returns the + * ID of the newly-created registration session. + */ + rpc send_verification_code (SendVerificationCodeRequest) returns (SendVerificationCodeResponse) {} + + /** + * Checks a client-provided verification code for a given registration + * session. + */ + rpc check_verification_code (CheckVerificationCodeRequest) returns (CheckVerificationCodeResponse) {} +} + +message SendVerificationCodeRequest { + /** + * The phone number to which to send a verification code. + */ + uint64 e164 = 1; + + /** + * The message transport to use to send a verification code to the destination + * phone number. + */ + MessageTransport transport = 2; + + /** + * The value of the `Accept-Language` header provided by remote clients (if + * any). + */ + string accept_language = 3; + + /** + * The type of client requesting a verification code. + */ + ClientType client_type = 4; +} + +enum MessageTransport { + MESSAGE_TRANSPORT_UNSPECIFIED = 0; + MESSAGE_TRANSPORT_SMS = 1; + MESSAGE_TRANSPORT_VOICE = 2; +} + +enum ClientType { + CLIENT_TYPE_UNSPECIFIED = 0; + CLIENT_TYPE_IOS = 1; + CLIENT_TYPE_ANDROID_WITH_FCM = 2; + CLIENT_TYPE_ANDROID_WITHOUT_FCM = 3; +} + +message SendVerificationCodeResponse { + /** + * An opaque sequence of bytes that uniquely identifies the registration + * session associated with this registration attempt. + */ + bytes session_id = 1; +} + +message CheckVerificationCodeRequest { + /** + * The session ID returned when sending a verification code. + */ + bytes session_id = 1; + + /** + * The client-provided verification code. + */ + string verification_code = 2; +} + +message CheckVerificationCodeResponse { + /** + * The outcome of the verification attempt; true if the verification code + * matched the expected code or false otherwise. + */ + bool verified = 1; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java index 130f27bbc..7caae76ec 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java @@ -22,9 +22,9 @@ class StoredVerificationCodeTest { private static Stream isValid() { return Stream.of( - Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "code", true), - Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "incorrect", false), - Arguments.of(new StoredVerificationCode("", System.currentTimeMillis(), null, null), "", false) + Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null, null), "code", true), + Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null, null), "incorrect", false), + Arguments.of(new StoredVerificationCode("", System.currentTimeMillis(), null, null, null), "", false) ); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java similarity index 94% rename from service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java rename to service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java index 06e0544e7..4bf67ff9e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.whispersystems.textsecuregcm.tests.controllers; +package org.whispersystems.textsecuregcm.controllers; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -26,12 +26,17 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.collect.ImmutableSet; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -66,8 +71,6 @@ import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import org.whispersystems.textsecuregcm.entities.AccountAttributes; import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse; import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; @@ -83,6 +86,7 @@ import org.whispersystems.textsecuregcm.entities.ReserveUsernameResponse; import org.whispersystems.textsecuregcm.entities.SignedPreKey; import org.whispersystems.textsecuregcm.entities.UsernameRequest; import org.whispersystems.textsecuregcm.entities.UsernameResponse; +import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; @@ -92,6 +96,9 @@ import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper import org.whispersystems.textsecuregcm.push.PushNotification; import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.recaptcha.RecaptchaClient; +import org.whispersystems.textsecuregcm.registration.ClientType; +import org.whispersystems.textsecuregcm.registration.MessageTransport; +import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.sms.SmsSender; import org.whispersystems.textsecuregcm.sms.TwilioVerifyExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.storage.AbusiveHostRules; @@ -152,6 +159,7 @@ class AccountControllerTest { private static RateLimiter usernameReserveLimiter = mock(RateLimiter.class); private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class); private static SmsSender smsSender = mock(SmsSender.class); + private static RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class); private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class); private static Account senderPinAccount = mock(Account.class); private static Account senderRegLockAccount = mock(Account.class); @@ -166,6 +174,8 @@ class AccountControllerTest { private static TwilioVerifyExperimentEnrollmentManager verifyExperimentEnrollmentManager = mock( TwilioVerifyExperimentEnrollmentManager.class); + private static ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class); + private byte[] registration_lock_key = new byte[32]; private static ExternalServiceCredentialGenerator storageCredentialGenerator = new ExternalServiceCredentialGenerator(new byte[32], new byte[32], false); @@ -185,6 +195,7 @@ class AccountControllerTest { abusiveHostRules, rateLimiters, smsSender, + registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, Map.of(TEST_NUMBER, TEST_VERIFICATION_CODE), @@ -192,7 +203,8 @@ class AccountControllerTest { pushNotificationManager, verifyExperimentEnrollmentManager, changeNumberManager, - storageCredentialGenerator)) + storageCredentialGenerator, + experimentEnrollmentManager)) .build(); @@ -233,15 +245,15 @@ class AccountControllerTest { when(senderTransfer.getUuid()).thenReturn(SENDER_TRANSFER_UUID); when(senderTransfer.getNumber()).thenReturn(SENDER_TRANSFER); - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), "1234-push", null))); + when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), "1234-push", null, null))); when(pendingAccountsManager.getCodeForNumber(SENDER_OLD)).thenReturn(Optional.empty()); - when(pendingAccountsManager.getCodeForNumber(SENDER_PIN)).thenReturn(Optional.of(new StoredVerificationCode("333333", System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)).thenReturn(Optional.of(new StoredVerificationCode("666666", System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PIN)).thenReturn(Optional.of(new StoredVerificationCode("444444", System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PREFIX)).thenReturn(Optional.of(new StoredVerificationCode("777777", System.currentTimeMillis(), "1234-push", null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_PREAUTH)).thenReturn(Optional.of(new StoredVerificationCode("555555", System.currentTimeMillis(), "validchallenge", null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_HAS_STORAGE)).thenReturn(Optional.of(new StoredVerificationCode("666666", System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)).thenReturn(Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_PIN)).thenReturn(Optional.of(new StoredVerificationCode("333333", System.currentTimeMillis(), null, null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)).thenReturn(Optional.of(new StoredVerificationCode("666666", System.currentTimeMillis(), null, null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PIN)).thenReturn(Optional.of(new StoredVerificationCode("444444", System.currentTimeMillis(), null, null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PREFIX)).thenReturn(Optional.of(new StoredVerificationCode("777777", System.currentTimeMillis(), "1234-push", null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_PREAUTH)).thenReturn(Optional.of(new StoredVerificationCode("555555", System.currentTimeMillis(), "validchallenge", null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_HAS_STORAGE)).thenReturn(Optional.of(new StoredVerificationCode("666666", System.currentTimeMillis(), null, null, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)).thenReturn(Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), null, null, null))); when(accountsManager.getByE164(eq(SENDER_PIN))).thenReturn(Optional.of(senderPinAccount)); when(accountsManager.getByE164(eq(SENDER_REG_LOCK))).thenReturn(Optional.of(senderRegLockAccount)); @@ -331,6 +343,7 @@ class AccountControllerTest { usernameReserveLimiter, usernameLookupLimiter, smsSender, + registrationServiceClient, turnTokenGenerator, senderPinAccount, senderRegLockAccount, @@ -339,6 +352,7 @@ class AccountControllerTest { recaptchaClient, pushNotificationManager, verifyExperimentEnrollmentManager, + experimentEnrollmentManager, changeNumberManager); clearInvocations(AuthHelper.DISABLED_DEVICE); @@ -464,7 +478,7 @@ class AccountControllerTest { @ParameterizedTest @ValueSource(booleans = {false, true}) - void testSendCode(final boolean enrolledInVerifyExperiment) throws Exception { + void testSendCode(final boolean enrolledInVerifyExperiment) { when(verifyExperimentEnrollmentManager.isEnrolled(any(), anyString(), anyList(), anyString())) .thenReturn(enrolledInVerifyExperiment); @@ -491,8 +505,8 @@ class AccountControllerTest { verify(smsSender).deliverSmsVerificationWithTwilioVerify(eq(SENDER), eq(Optional.empty()), anyString(), eq(Collections.emptyList())); verify(pendingAccountsManager, times(2)).store(eq(SENDER), storedVerificationCodeArgumentCaptor.capture()); - assertThat(storedVerificationCodeArgumentCaptor.getValue().getTwilioVerificationSid()) - .isEqualTo(Optional.of("VerificationSid")); + assertThat(storedVerificationCodeArgumentCaptor.getValue().twilioVerificationSid()) + .isEqualTo("VerificationSid"); } else { verify(smsSender).deliverSmsVerification(eq(SENDER), eq(Optional.empty()), anyString()); @@ -501,6 +515,37 @@ class AccountControllerTest { verify(abusiveHostRules).isBlocked(eq(NICE_HOST)); } + @Test + void testSendCodeViaRegistrationService() throws NumberParseException { + + final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); + + when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(sessionId)); + + when(experimentEnrollmentManager.isEnrolled(SENDER, AccountController.REGISTRATION_SERVICE_EXPERIMENT_NAME)) + .thenReturn(true); + + Response response = + resources.getJerseyTest() + .target(String.format("/v1/accounts/sms/code/%s", SENDER)) + .queryParam("challenge", "1234-push") + .request() + .header("X-Forwarded-For", NICE_HOST) + .get(); + + assertThat(response.getStatus()).isEqualTo(200); + + final Phonenumber.PhoneNumber expectedPhoneNumber = PhoneNumberUtil.getInstance().parse(SENDER, null); + + verify(registrationServiceClient).sendRegistrationCode(expectedPhoneNumber, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); + verify(abusiveHostRules).isBlocked(eq(NICE_HOST)); + verify(pendingAccountsManager).store(eq(SENDER), argThat( + storedVerificationCode -> Arrays.equals(storedVerificationCode.sessionId(), sessionId) && + "1234-push".equals(storedVerificationCode.pushCode()))); + + verifyNoInteractions(smsSender); + } @Test void testSendCodeImpossibleNumber() { @@ -996,7 +1041,7 @@ class AccountControllerTest { final String challenge = "challenge"; when(pendingAccountsManager.getCodeForNumber(RESTRICTED_NUMBER)) - .thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null))); + .thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null, null))); Response response = resources.getJerseyTest() @@ -1033,7 +1078,7 @@ class AccountControllerTest { final String challenge = "challenge"; when(pendingAccountsManager.getCodeForNumber(number)) - .thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null))); + .thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null, null))); when(smsSender.deliverSmsVerificationWithTwilioVerify(any(), any(), any(), any())) .thenReturn(CompletableFuture.completedFuture(Optional.empty())); @@ -1073,7 +1118,7 @@ class AccountControllerTest { } final String challenge = "challenge"; - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null))); + when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode("123456", System.currentTimeMillis(), challenge, null, null))); Response response = resources.getJerseyTest() @@ -1106,7 +1151,7 @@ class AccountControllerTest { .get(); ArgumentCaptor captor = ArgumentCaptor.forClass(StoredVerificationCode.class); verify(pendingAccountsManager).store(eq(TEST_NUMBER), captor.capture()); - assertThat(captor.getValue().getCode()).isEqualTo(Integer.toString(TEST_VERIFICATION_CODE)); + assertThat(captor.getValue().code()).isEqualTo(Integer.toString(TEST_VERIFICATION_CODE)); assertThat(response.getStatus()).isEqualTo(200); verifyNoInteractions(smsSender); } @@ -1116,7 +1161,7 @@ class AccountControllerTest { void testVerifyCode(final boolean enrolledInVerifyExperiment) throws Exception { if (enrolledInVerifyExperiment) { when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn( - Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), "1234-push", "VerificationSid"))); + Optional.of(new StoredVerificationCode("1234", System.currentTimeMillis(), "1234-push", "VerificationSid", null))); } resources.getJerseyTest() @@ -1130,6 +1175,31 @@ class AccountControllerTest { if (enrolledInVerifyExperiment) { verify(smsSender).reportVerificationSucceeded(eq("VerificationSid"), any(), eq("registration")); } + + verifyNoInteractions(registrationServiceClient); + } + + @Test + void testVerifyCodeWithRegistrationService() throws Exception { + final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); + + when(pendingAccountsManager.getCodeForNumber(SENDER)) + .thenReturn(Optional.of( + new StoredVerificationCode("1234", System.currentTimeMillis(), "1234-push", null, sessionId))); + + when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT)) + .thenReturn(CompletableFuture.completedFuture(true)); + + resources.getJerseyTest() + .target(String.format("/v1/accounts/code/%s", "1234")) + .request() + .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER, "bar")) + .put(Entity.entity(new AccountAttributes(), MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); + + verify(accountsManager).create(eq(SENDER), eq("bar"), any(), any(), anyList()); + + verify(registrationServiceClient).checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT); + verifyNoInteractions(smsSender); } @Test @@ -1173,6 +1243,32 @@ class AccountControllerTest { verifyNoMoreInteractions(accountsManager); } + @Test + void testVerifyBadCodeWithRegistrationService() { + final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); + + when(pendingAccountsManager.getCodeForNumber(SENDER)) + .thenReturn(Optional.of( + new StoredVerificationCode("1234", System.currentTimeMillis(), "1234-push", null, sessionId))); + + when(registrationServiceClient.checkVerificationCode(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(false)); + + Response response = + resources.getJerseyTest() + .target(String.format("/v1/accounts/code/%s", "1111")) + .request() + .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER, "bar")) + .put(Entity.entity(new AccountAttributes(false, 3333, null, null, true, null), + MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(403); + + verify(registrationServiceClient).checkVerificationCode(sessionId, "1111", AccountController.REGISTRATION_RPC_TIMEOUT); + verifyNoInteractions(accountsManager); + verifyNoInteractions(smsSender); + } + @Test void testVerifyRegistrationLock() throws Exception { AccountIdentityResponse result = @@ -1319,7 +1415,7 @@ class AccountControllerTest { void testVerifyTestDeviceNumber() throws Exception { when(pendingAccountsManager.getCodeForNumber(TEST_NUMBER)).thenReturn(Optional.of( - new StoredVerificationCode(Integer.toString(TEST_VERIFICATION_CODE), System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(Integer.toString(TEST_VERIFICATION_CODE), System.currentTimeMillis(), "push", null, null))); final Response response = resources.getJerseyTest() @@ -1339,7 +1435,7 @@ class AccountControllerTest { final String code = "987654"; when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); final AccountIdentityResponse accountIdentityResponse = resources.getJerseyTest() @@ -1434,7 +1530,7 @@ class AccountControllerTest { final String code = "987654"; when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); final Response response = resources.getJerseyTest() @@ -1454,7 +1550,7 @@ class AccountControllerTest { final String code = "987654"; when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(false); @@ -1484,7 +1580,7 @@ class AccountControllerTest { final String code = "987654"; when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); @@ -1515,7 +1611,7 @@ class AccountControllerTest { final String reglock = "setec-astronomy"; when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); @@ -1547,7 +1643,7 @@ class AccountControllerTest { final String reglock = "setec-astronomy"; when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); @@ -1593,7 +1689,7 @@ class AccountControllerTest { when(AuthHelper.VALID_ACCOUNT.getDevice(3L)).thenReturn(Optional.of(device3)); when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", null))); + new StoredVerificationCode(code, System.currentTimeMillis(), "push", null, null))); var deviceMessages = List.of( new IncomingMessage(1, 2, 2, "content2"), diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java index 2249d6e05..28b21731a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java @@ -9,8 +9,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.Objects; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -51,8 +53,8 @@ class VerificationCodeStoreTest { void testStoreAndFind() { assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER)); - final StoredVerificationCode originalCode = new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "0987"); - final StoredVerificationCode secondCode = new StoredVerificationCode("5678", VALID_TIMESTAMP, "efgh", "7890"); + final StoredVerificationCode originalCode = new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "0987", "session".getBytes(StandardCharsets.UTF_8)); + final StoredVerificationCode secondCode = new StoredVerificationCode("5678", VALID_TIMESTAMP, "efgh", "7890", "changed-session".getBytes(StandardCharsets.UTF_8)); verificationCodeStore.insert(PHONE_NUMBER, originalCode); { @@ -75,13 +77,13 @@ class VerificationCodeStoreTest { void testRemove() { assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER)); - verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "0987")); + verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "0987", "session".getBytes(StandardCharsets.UTF_8))); assertTrue(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent()); verificationCodeStore.remove(PHONE_NUMBER); assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent()); - verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", EXPIRED_TIMESTAMP, "abcd", "0987")); + verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", EXPIRED_TIMESTAMP, "abcd", "0987", "session".getBytes(StandardCharsets.UTF_8))); assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent()); } @@ -92,9 +94,10 @@ class VerificationCodeStoreTest { return false; } - return Objects.equals(first.getCode(), second.getCode()) && - first.getTimestamp() == second.getTimestamp() && - Objects.equals(first.getPushCode(), second.getPushCode()) && - Objects.equals(first.getTwilioVerificationSid(), second.getTwilioVerificationSid()); + return Objects.equals(first.code(), second.code()) && + first.timestamp() == second.timestamp() && + Objects.equals(first.pushCode(), second.pushCode()) && + Objects.equals(first.twilioVerificationSid(), second.twilioVerificationSid()) && + Arrays.equals(first.sessionId(), second.sessionId()); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java index 865d2ff6a..4e549f7d2 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java @@ -132,7 +132,7 @@ class DeviceControllerTest { when(account.isGiftBadgesSupported()).thenReturn(true); when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER)).thenReturn( - Optional.of(new StoredVerificationCode("5678901", System.currentTimeMillis(), null, null))); + Optional.of(new StoredVerificationCode("5678901", System.currentTimeMillis(), null, null, null))); when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.empty()); when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(account)); when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(maxedAccount));