diff --git a/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt b/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt index b510845ac4..0d767d3894 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/absbackup/backupables/KbsAuthTokens.kt @@ -17,8 +17,7 @@ object KbsAuthTokens : AndroidBackupItem { } override fun getDataForBackup(): ByteArray { - val registrationRecoveryTokenList = SignalStore.kbsValues().kbsAuthTokenList - val proto = KbsAuthToken(tokens = registrationRecoveryTokenList) + val proto = KbsAuthToken(tokens = SignalStore.kbsValues().kbsAuthTokenList) return proto.encode() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/registration/ActionCountDownButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/registration/ActionCountDownButton.kt index 0c9994ce0f..703f359948 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/registration/ActionCountDownButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/registration/ActionCountDownButton.kt @@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.components.registration import android.content.Context import android.util.AttributeSet +import androidx.annotation.StringRes import com.google.android.material.button.MaterialButton -import org.thoughtcrime.securesms.R import java.util.concurrent.TimeUnit class ActionCountDownButton @JvmOverloads constructor( @@ -11,6 +11,12 @@ class ActionCountDownButton @JvmOverloads constructor( attrs: AttributeSet? = null, defStyle: Int = 0 ) : MaterialButton(context, attrs, defStyle) { + @StringRes + private var enabledText = 0 + + @StringRes + private var disabledText = 0 + private var countDownToTime: Long = 0 private var listener: Listener? = null @@ -24,8 +30,8 @@ class ActionCountDownButton @JvmOverloads constructor( } } - fun setCallEnabled() { - setText(R.string.RegistrationActivity_call) + private fun setActionEnabled() { + setText(enabledText) isEnabled = true alpha = 1.0f } @@ -38,11 +44,11 @@ class ActionCountDownButton @JvmOverloads constructor( val totalRemainingSeconds = TimeUnit.MILLISECONDS.toSeconds(remainingMillis).toInt() val minutesRemaining = totalRemainingSeconds / 60 val secondsRemaining = totalRemainingSeconds % 60 - text = resources.getString(R.string.RegistrationActivity_call_me_instead_available_in, minutesRemaining, secondsRemaining) + text = resources.getString(disabledText, minutesRemaining, secondsRemaining) listener?.onRemaining(this, totalRemainingSeconds) postDelayed({ updateCountDown() }, 250) } else { - setCallEnabled() + setActionEnabled() } } @@ -50,6 +56,11 @@ class ActionCountDownButton @JvmOverloads constructor( this.listener = listener } + fun setTextResources(@StringRes enabled: Int, @StringRes disabled: Int) { + enabledText = enabled + disabledText = disabled + } + interface Listener { fun onRemaining(view: ActionCountDownButton, secondsRemaining: Int) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt index c5fd14b092..699815857f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberVerifyFragment.kt @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel import org.thoughtcrime.securesms.registration.VerifyAccountRepository import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.dualsim.MccMncProducer import org.thoughtcrime.securesms.util.navigation.safeNavigate private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java) @@ -48,17 +49,15 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon } private fun requestCode() { + val mccMncProducer = MccMncProducer(requireContext()) lifecycleDisposable += viewModel .ensureDecryptionsDrained() .onErrorComplete() - .andThen(viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER)) + .andThen(viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, mccMncProducer.mcc, mccMncProducer.mnc)) .observeOn(AndroidSchedulers.mainThread()) .subscribe { processor -> if (processor.hasResult()) { findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment) - } else if (processor.localRateLimit()) { - Log.i(TAG, "Unable to request sms code due to local rate limit") - findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment) } else if (processor.captchaRequired()) { Log.i(TAG, "Unable to request sms code due to captcha required") findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index faa1d02f58..eb46046b0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -83,14 +83,15 @@ public class RefreshAttributesJob extends BaseJob { return; } - int registrationId = SignalStore.account().getRegistrationId(); - boolean fetchesMessages = !SignalStore.account().isFcmEnabled() || SignalStore.internalValues().isWebsocketModeForced(); - byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey()); - boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context); - String registrationLockV1 = null; - String registrationLockV2 = null; - KbsValues kbsValues = SignalStore.kbsValues(); - int pniRegistrationId = new RegistrationRepository(ApplicationDependencies.getApplication()).getPniRegistrationId(); + int registrationId = SignalStore.account().getRegistrationId(); + boolean fetchesMessages = !SignalStore.account().isFcmEnabled() || SignalStore.internalValues().isWebsocketModeForced(); + byte[] unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(ProfileKeyUtil.getSelfProfileKey()); + boolean universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context); + String registrationLockV1 = null; + String registrationLockV2 = null; + KbsValues kbsValues = SignalStore.kbsValues(); + int pniRegistrationId = new RegistrationRepository(ApplicationDependencies.getApplication()).getPniRegistrationId(); + String registrationRecoveryPassword = kbsValues.getRegistrationRecoveryPassword(); if (kbsValues.isV2RegistrationLockEnabled()) { registrationLockV2 = kbsValues.getRegistrationLockToken(); @@ -121,7 +122,8 @@ public class RefreshAttributesJob extends BaseJob { capabilities, phoneNumberDiscoverable, encryptedDeviceName, - pniRegistrationId); + pniRegistrationId, + registrationRecoveryPassword); hasRefreshedThisAppCycle = true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java index dc54a90ce5..58a2f0c079 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/KbsValues.java @@ -149,6 +149,15 @@ public final class KbsValues extends SignalStoreValues { } } + public synchronized @Nullable String getRegistrationRecoveryPassword() { + MasterKey masterKey = getPinBackedMasterKey(); + if (masterKey == null) { + return null; + } else { + return masterKey.deriveRegistrationRecoveryPassword(); + } + } + public synchronized @Nullable String getPin() { return getString(PIN, null); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java index 0b955064cc..273cff76f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.keyvalue; import androidx.annotation.CheckResult; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.Collections; import java.util.List; @@ -11,6 +12,8 @@ public final class RegistrationValues extends SignalStoreValues { private static final String REGISTRATION_COMPLETE = "registration.complete"; private static final String PIN_REQUIRED = "registration.pin_required"; private static final String HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile"; + private static final String SESSION_E164 = "registration.session_e164"; + private static final String SESSION_ID = "registration.session_id"; RegistrationValues(@NonNull KeyValueStore store) { super(store); @@ -60,4 +63,22 @@ public final class RegistrationValues extends SignalStoreValues { public void clearHasUploadedProfile() { putBoolean(HAS_UPLOADED_PROFILE, false); } + + public void setSessionId(String sessionId) { + putString(SESSION_ID, sessionId); + } + + @Nullable + public String getSessionId() { + return getString(SESSION_ID, null); + } + + public void setSessionE164(String sessionE164) { + putString(SESSION_E164, sessionE164); + } + + @Nullable + public String getSessionE164() { + return getString(SESSION_E164, null); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java index bb1b8d5c70..de1f7e3900 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/PushChallengeRequest.java @@ -7,6 +7,7 @@ import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import java.io.IOException; @@ -27,14 +28,14 @@ public final class PushChallengeRequest { * * @param accountManager Account manager to request the push from. * @param fcmToken Optional FCM token. If not present will return absent. - * @param e164number Local number. + * @param sessionId Local number. * @param timeoutMs Timeout in milliseconds * @return Either returns a challenge, or absent. */ @WorkerThread public static Optional getPushChallengeBlocking(@NonNull SignalServiceAccountManager accountManager, + @NonNull String sessionId, @NonNull Optional fcmToken, - @NonNull String e164number, long timeoutMs) { if (!fcmToken.isPresent()) { @@ -44,8 +45,7 @@ public final class PushChallengeRequest { long startTime = System.currentTimeMillis(); Log.i(TAG, "Requesting a push challenge"); - - Request request = new Request(accountManager, fcmToken.get(), e164number, timeoutMs); + Request request = new Request(accountManager, fcmToken.get(), sessionId, timeoutMs); Optional challenge = request.requestAndReceiveChallengeBlocking(); @@ -69,19 +69,19 @@ public final class PushChallengeRequest { private final AtomicReference challenge; private final SignalServiceAccountManager accountManager; private final String fcmToken; - private final String e164number; + private final String sessionId; private final long timeoutMs; private Request(@NonNull SignalServiceAccountManager accountManager, @NonNull String fcmToken, - @NonNull String e164number, + @NonNull String sessionId, long timeoutMs) { this.latch = new CountDownLatch(1); this.challenge = new AtomicReference<>(); this.accountManager = accountManager; this.fcmToken = fcmToken; - this.e164number = e164number; + this.sessionId = sessionId; this.timeoutMs = timeoutMs; } @@ -91,7 +91,7 @@ public final class PushChallengeRequest { eventBus.register(this); try { - accountManager.requestRegistrationPushChallenge(fcmToken, e164number); + accountManager.requestRegistrationPushChallenge(sessionId, fcmToken); latch.await(timeoutMs, TimeUnit.MILLISECONDS); @@ -117,5 +117,9 @@ public final class PushChallengeRequest { PushChallengeEvent(String challenge) { this.challenge = challenge; } + + public String getChallenge() { + return challenge; + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationData.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationData.kt index 8ae01102dd..a310b9027c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationData.kt @@ -9,7 +9,8 @@ data class RegistrationData( val registrationId: Int, val profileKey: ProfileKey, val fcmToken: String?, - val pniRegistrationId: Int + val pniRegistrationId: Int, + val recoveryPassword: String? ) { val isFcm: Boolean = fcmToken != null val isNotFcm: Boolean = fcmToken == null diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java index eb94a60ae2..f1391937e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationRepository.java @@ -209,4 +209,9 @@ public final class RegistrationRepository { return null; } + + @Nullable + public String getRecoveryPassword() { + return SignalStore.kbsValues().getRegistrationRecoveryPassword(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationSessionProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationSessionProcessor.kt new file mode 100644 index 0000000000..066d625559 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationSessionProcessor.kt @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.registration + +import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException +import org.whispersystems.signalservice.api.push.exceptions.ExternalServiceFailureException +import org.whispersystems.signalservice.api.push.exceptions.ImpossiblePhoneNumberException +import org.whispersystems.signalservice.api.push.exceptions.InvalidTransportModeException +import org.whispersystems.signalservice.api.push.exceptions.MustRequestNewCodeException +import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException +import org.whispersystems.signalservice.api.push.exceptions.NonNormalizedPhoneNumberException +import org.whispersystems.signalservice.api.push.exceptions.RateLimitException +import org.whispersystems.signalservice.api.util.Preconditions +import org.whispersystems.signalservice.internal.ServiceResponse +import org.whispersystems.signalservice.internal.ServiceResponseProcessor +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse +import kotlin.time.Duration.Companion.seconds + +/** + * Makes the server's response describing the state of the registration session as digestible as possible. + */ +sealed class RegistrationSessionProcessor(response: ServiceResponse) : ServiceResponseProcessor(response) { + + companion object { + const val CAPTCHA_KEY = "captcha" + const val PUSH_CHALLENGE_KEY = "pushChallenge" + val REQUESTABLE_INFORMATION = listOf(PUSH_CHALLENGE_KEY, CAPTCHA_KEY) + } + + public override fun captchaRequired(): Boolean { + return super.captchaRequired() || (hasResult() && CAPTCHA_KEY == getChallenge()) + } + + public override fun rateLimit(): Boolean { + return error is RateLimitException + } + + public override fun getError(): Throwable? { + return super.getError() + } + + fun pushChallengeRequired(): Boolean { + return PUSH_CHALLENGE_KEY == getChallenge() + } + + fun isImpossibleNumber(): Boolean { + return error is ImpossiblePhoneNumberException + } + + fun isNonNormalizedNumber(): Boolean { + return error is NonNormalizedPhoneNumberException + } + + fun getRateLimit(): Long { + Preconditions.checkState(error is RateLimitException, "This can only be called when isRateLimited()") + return (error as RateLimitException).retryAfterMilliseconds.orElse(-1L) + } + + /** + * The soonest time at which the server will accept a request to send a new code via SMS. + * @return a unix timestamp in milliseconds, or 0 to represent null + */ + fun getNextCodeViaSmsAttempt(): Long { + return deriveTimestamp(result.body.nextSms) + } + + /** + * The soonest time at which the server will accept a request to send a new code via a voice call. + * @return a unix timestamp in milliseconds, or 0 to represent null + */ + fun getNextCodeViaCallAttempt(): Long { + return deriveTimestamp(result.body.nextCall) + } + + fun canSubmitProofImmediately(): Boolean { + Preconditions.checkState(hasResult(), "This can only be called when result is present!") + return 0 == result.body.nextVerificationAttempt + } + + /** + * The soonest time at which the server will accept a submission of proof of ownership. + * @return a unix timestamp in milliseconds, or 0 to represent null + */ + fun getNextProofSubmissionAttempt(): Long { + Preconditions.checkState(hasResult(), "This can only be called when result is present!") + return deriveTimestamp(result.body.nextVerificationAttempt) + } + + fun exhaustedVerificationCodeAttempts(): Boolean { + return rateLimit() && getRateLimit() == -1L + } + + fun isInvalidSession(): Boolean { + return error is NoSuchSessionException + } + + fun getSessionId(): String { + Preconditions.checkState(hasResult(), "This can only be called when result is present!") + return result.body.id + } + + fun isAllowedToRequestCode(): Boolean { + Preconditions.checkState(hasResult(), "This can only be called when result is present!") + return result.body.allowedToRequestCode + } + + fun getChallenge(): String? { + Preconditions.checkState(hasResult(), "This can only be called when result is present!") + return result.body.requestedInformation.firstOrNull { REQUESTABLE_INFORMATION.contains(it) } + } + + fun isVerified(): Boolean { + return hasResult() && result.body.verified + } + + /** Should only be called if [isNonNormalizedNumber] */ + fun getOriginalNumber(): String { + if (error !is NonNormalizedPhoneNumberException) { + throw IllegalStateException("This can only be called when isNonNormalizedNumber()") + } + + return (error as NonNormalizedPhoneNumberException).originalNumber + } + + /** Should only be called if [isNonNormalizedNumber] */ + fun getNormalizedNumber(): String { + if (error !is NonNormalizedPhoneNumberException) { + throw IllegalStateException("This can only be called when isNonNormalizedNumber()") + } + + return (error as NonNormalizedPhoneNumberException).normalizedNumber + } + + fun cannotSubmitVerificationAttempt(): Boolean { + return !hasResult() || result.body.nextVerificationAttempt == null + } + + /** + * @param deltaSeconds the number of whole seconds to be added to the server timestamp + * @return a unix timestamp in milliseconds, or 0 to represent null + */ + private fun deriveTimestamp(deltaSeconds: Int?): Long { + Preconditions.checkState(hasResult(), "This can only be called when result is present!") + + if (deltaSeconds == null) { + return 0L + } + + val timestamp: Long = result.headers.timestamp + return timestamp + deltaSeconds.seconds.inWholeMilliseconds + } + + abstract fun verificationCodeRequestSuccess(): Boolean + + class RegistrationSessionProcessorForSession(response: ServiceResponse) : RegistrationSessionProcessor(response) { + + override fun verificationCodeRequestSuccess(): Boolean = false + } + + class RegistrationSessionProcessorForVerification(response: ServiceResponse) : RegistrationSessionProcessor(response) { + override fun verificationCodeRequestSuccess(): Boolean = hasResult() + + fun isAlreadyVerified(): Boolean { + return error is AlreadyVerifiedException + } + + fun mustRequestNewCode(): Boolean { + return error is MustRequestNewCodeException + } + + fun externalServiceFailure(): Boolean { + return error is ExternalServiceFailureException + } + + fun invalidTransportModeFailure(): Boolean { + return error is InvalidTransportModeException + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RequestVerificationCodeResponseProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/RequestVerificationCodeResponseProcessor.kt deleted file mode 100644 index 2dca2cefd6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RequestVerificationCodeResponseProcessor.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.thoughtcrime.securesms.registration - -import org.whispersystems.signalservice.api.push.exceptions.ImpossiblePhoneNumberException -import org.whispersystems.signalservice.api.push.exceptions.LocalRateLimitException -import org.whispersystems.signalservice.api.push.exceptions.NonNormalizedPhoneNumberException -import org.whispersystems.signalservice.internal.ServiceResponse -import org.whispersystems.signalservice.internal.ServiceResponseProcessor -import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse - -/** - * Process responses from requesting an SMS or Phone code from the server. - */ -class RequestVerificationCodeResponseProcessor(response: ServiceResponse) : ServiceResponseProcessor(response) { - public override fun captchaRequired(): Boolean { - return super.captchaRequired() - } - - public override fun rateLimit(): Boolean { - return super.rateLimit() - } - - public override fun getError(): Throwable? { - return super.getError() - } - - fun localRateLimit(): Boolean { - return error is LocalRateLimitException - } - - fun isImpossibleNumber(): Boolean { - return error is ImpossiblePhoneNumberException - } - - fun isNonNormalizedNumber(): Boolean { - return error is NonNormalizedPhoneNumberException - } - - /** Should only be called if [isNonNormalizedNumber] */ - fun getOriginalNumber(): String { - if (error !is NonNormalizedPhoneNumberException) { - throw IllegalStateException("This can only be called when isNonNormalizedNumber()") - } - - return (error as NonNormalizedPhoneNumberException).originalNumber - } - - /** Should only be called if [isNonNormalizedNumber] */ - fun getNormalizedNumber(): String { - if (error !is NonNormalizedPhoneNumberException) { - throw IllegalStateException("This can only be called when isNonNormalizedNumber()") - } - - return (error as NonNormalizedPhoneNumberException).normalizedNumber - } - - companion object { - @JvmStatic - fun forLocalRateLimit(): RequestVerificationCodeResponseProcessor { - val response: ServiceResponse = ServiceResponse.forExecutionError(LocalRateLimitException()) - return RequestVerificationCodeResponseProcessor(response) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyAccountRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyAccountRepository.kt index b1fbfee2d3..68424ba0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyAccountRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyAccountRepository.kt @@ -3,24 +3,29 @@ package org.thoughtcrime.securesms.registration import android.app.Application import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.AppCapabilities import org.thoughtcrime.securesms.gcm.FcmUtil import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException import org.thoughtcrime.securesms.push.AccountManagerFactory +import org.thoughtcrime.securesms.registration.PushChallengeRequest.PushChallengeEvent import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.KbsPinData import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException import org.whispersystems.signalservice.api.SignalServiceAccountManager +import org.whispersystems.signalservice.api.account.AccountAttributes import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException import org.whispersystems.signalservice.internal.ServiceResponse -import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse -import org.whispersystems.signalservice.internal.push.VerifyAccountResponse +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse import java.io.IOException import java.util.Locale import java.util.Optional +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** @@ -28,31 +33,105 @@ import java.util.concurrent.TimeUnit */ class VerifyAccountRepository(private val context: Application) { - fun requestVerificationCode( + fun validateSession( + sessionId: String?, + e164: String, + password: String + ): Single> { + return if (sessionId.isNullOrBlank()) { + Single.just(ServiceResponse.forApplicationError(NoSuchSessionException(), 409, null)) + } else { + val accountManager: SignalServiceAccountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password) + Single.fromCallable { accountManager.getRegistrationSession(sessionId) }.subscribeOn(Schedulers.io()) + } + } + + fun requestValidSession( e164: String, password: String, - mode: Mode, - captchaToken: String? = null - ): Single> { + mcc: String?, + mnc: String? + ): Single> { + return Single.fromCallable { + val fcmToken: String? = FcmUtil.getToken(context).orElse(null) + val accountManager: SignalServiceAccountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password) + if (fcmToken == null) { + return@fromCallable accountManager.createRegistrationSession(null, mcc, mnc) + } else { + return@fromCallable createSessionAndBlockForPushChallenge(accountManager, fcmToken, mcc, mnc) + } + } + .subscribeOn(Schedulers.io()) + } + + private fun createSessionAndBlockForPushChallenge(accountManager: SignalServiceAccountManager, fcmToken: String, mcc: String?, mnc: String?): ServiceResponse { + val subscriber = PushTokenChallengeSubscriber() + val eventBus = EventBus.getDefault() + eventBus.register(subscriber) + + val response: ServiceResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc) + + if (!response.result.isPresent) { + return response + } + + subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS) + + eventBus.unregister(subscriber) + + val challenge = subscriber.challenge + + return if (challenge != null) { + accountManager.submitPushChallengeToken(response.result.get().body.id, challenge) + } else { + response + } + } + + fun requestAndVerifyPushToken( + sessionId: String, + e164: String, + password: String + ): Single> { + val fcmToken: Optional = FcmUtil.getToken(context) + val accountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password) + val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, fcmToken, PUSH_REQUEST_TIMEOUT) + return Single.fromCallable { + return@fromCallable accountManager.submitPushChallengeToken(sessionId, pushChallenge.orElse(null)) + }.subscribeOn(Schedulers.io()) + } + + fun verifyCaptcha( + sessionId: String, + captcha: String, + e164: String, + password: String + ): Single> { + val accountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password) + return Single.fromCallable { + return@fromCallable accountManager.submitCaptchaToken(sessionId, captcha) + }.subscribeOn(Schedulers.io()) + } + + fun requestVerificationCode( + sessionId: String, + e164: String, + password: String, + mode: Mode + ): Single> { Log.d(TAG, "SMS Verification requested") return Single.fromCallable { - val fcmToken: Optional = FcmUtil.getToken(context) val accountManager = AccountManagerFactory.createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password) - val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, fcmToken, e164, PUSH_REQUEST_TIMEOUT) - if (mode == Mode.PHONE_CALL) { - accountManager.requestVoiceVerificationCode(Locale.getDefault(), Optional.ofNullable(captchaToken), pushChallenge, fcmToken) + return@fromCallable accountManager.requestVoiceVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported) } else { - accountManager.requestSmsVerificationCode(Locale.getDefault(), mode.isSmsRetrieverSupported, Optional.ofNullable(captchaToken), pushChallenge, fcmToken) + return@fromCallable accountManager.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported) } }.subscribeOn(Schedulers.io()) } - fun verifyAccount(registrationData: RegistrationData): Single> { - val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context) - val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey) - + fun verifyAccount(sessionId: String, registrationData: RegistrationData): Single> { val accountManager: SignalServiceAccountManager = AccountManagerFactory.createUnauthenticated( context, registrationData.e164, @@ -61,21 +140,14 @@ class VerifyAccountRepository(private val context: Application) { ) return Single.fromCallable { - val response = accountManager.verifyAccount( + accountManager.verifyAccount( registrationData.code, - registrationData.registrationId, - registrationData.isNotFcm, - unidentifiedAccessKey, - universalUnidentifiedAccess, - AppCapabilities.getCapabilities(true), - SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isDiscoverable, - registrationData.pniRegistrationId + sessionId ) - VerifyResponse.from(response, null, null) }.subscribeOn(Schedulers.io()) } - fun verifyAccountWithPin(registrationData: RegistrationData, pin: String, kbsPinDataProducer: KbsPinDataProducer): Single> { + fun registerAccount(sessionId: String, registrationData: RegistrationData, pin: String? = null, kbsPinDataProducer: KbsPinDataProducer? = null): Single> { val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context) val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey) @@ -86,30 +158,27 @@ class VerifyAccountRepository(private val context: Application) { registrationData.password ) - return Single.fromCallable { - try { - val kbsData = kbsPinDataProducer.produceKbsPinData() - val registrationLockV2: String = kbsData.masterKey.deriveRegistrationLock() + val kbsData = kbsPinDataProducer?.produceKbsPinData() + val registrationLockV2: String? = kbsData?.masterKey?.deriveRegistrationLock() - val response: ServiceResponse = accountManager.verifyAccountWithRegistrationLockPin( - registrationData.code, - registrationData.registrationId, - registrationData.isNotFcm, - registrationLockV2, - unidentifiedAccessKey, - universalUnidentifiedAccess, - AppCapabilities.getCapabilities(true), - SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isDiscoverable, - registrationData.pniRegistrationId - ) - VerifyResponse.from(response, kbsData, pin) - } catch (e: KeyBackupSystemWrongPinException) { - ServiceResponse.forExecutionError(e) - } catch (e: KeyBackupSystemNoDataException) { - ServiceResponse.forExecutionError(e) - } catch (e: IOException) { - ServiceResponse.forExecutionError(e) - } + val accountAttributes = AccountAttributes( + signalingKey = null, + registrationId = registrationData.registrationId, + isFetchesMessages = registrationData.isNotFcm, + pin = pin, + registrationLock = registrationLockV2, + unidentifiedAccessKey = unidentifiedAccessKey, + isUnrestrictedUnidentifiedAccess = universalUnidentifiedAccess, + capabilities = AppCapabilities.getCapabilities(true), + isDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isDiscoverable, + name = null, + pniRegistrationId = registrationData.pniRegistrationId, + recoveryPassword = registrationData.recoveryPassword + ) + + return Single.fromCallable { + val response = accountManager.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, true) + VerifyResponse.from(response, kbsData, pin) }.subscribeOn(Schedulers.io()) } @@ -124,6 +193,17 @@ class VerifyAccountRepository(private val context: Application) { PHONE_CALL(false); } + private class PushTokenChallengeSubscriber { + var challenge: String? = null + val latch = CountDownLatch(1) + + @Subscribe + fun onChallengeEvent(pushChallengeEvent: PushChallengeEvent) { + challenge = pushChallengeEvent.challenge + latch.countDown() + } + } + companion object { private val TAG = Log.tag(VerifyAccountRepository::class.java) private val PUSH_REQUEST_TIMEOUT = TimeUnit.SECONDS.toMillis(5) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyResponseProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyResponseProcessor.kt index 057dc94ddd..5697b9a4e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyResponseProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/VerifyResponseProcessor.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.registration import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException import org.thoughtcrime.securesms.pin.TokenData import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException +import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.internal.ServiceResponse import org.whispersystems.signalservice.internal.ServiceResponseProcessor @@ -32,6 +33,10 @@ sealed class VerifyResponseProcessor(response: ServiceResponse) return super.getError() } + fun invalidSession(): Boolean { + return error is NoSuchSessionException + } + fun getLockedException(): LockedException { return error as LockedException } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterSmsCodeFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterSmsCodeFragment.java index e2d779e138..606e2a06e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterSmsCodeFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/BaseEnterSmsCodeFragment.java @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.registration.fragments; -import android.animation.Animator; import android.os.Bundle; import android.view.View; import android.widget.ScrollView; @@ -11,6 +10,7 @@ import androidx.annotation.CallSuper; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.navigation.Navigation; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.SupportEmailUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; +import org.thoughtcrime.securesms.util.dualsim.MccMncProducer; import org.whispersystems.signalservice.internal.push.LockedException; import java.util.ArrayList; @@ -59,6 +60,7 @@ public abstract class BaseEnterSmsCodeFragment onWrongNumber()); + wrongNumber.setOnClickListener(v -> returnToPhoneEntryScreen()); + + callMeCountDown.setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in); + resendSmsCountDown.setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in); callMeCountDown.setOnClickListener(v -> handlePhoneCallRequest()); + resendSmsCountDown.setOnClickListener(v -> handleSmsRequest()); callMeCountDown.setListener((v, remaining) -> { if (remaining <= 30) { @@ -102,16 +109,21 @@ public abstract class BaseEnterSmsCodeFragment { + if (remaining <= 30) { + scrollView.smoothScrollTo(0, v.getBottom()); + resendSmsCountDown.setListener(null); + } + }); + disposables.bindTo(getViewLifecycleOwner().getLifecycle()); viewModel = getViewModel(); viewModel.getSuccessfulCodeRequestAttempts().observe(getViewLifecycleOwner(), (attempts) -> { if (attempts >= 3) { - // TODO Add bottom sheet for help + new ContactSupportBottomSheetFragment(this::openTroubleshootingSteps, this::sendEmailToSupport).show(getChildFragmentManager(), "support_bottom_sheet"); } }); - - viewModel.onStartEnterCode(); } protected abstract ViewModel getViewModel(); @@ -124,7 +136,8 @@ public abstract class BaseEnterSmsCodeFragment { callMeCountDown.setVisibility(View.INVISIBLE); + resendSmsCountDown.setVisibility(View.INVISIBLE); wrongNumber.setVisibility(View.INVISIBLE); keyboard.displayProgress(); @@ -181,6 +195,7 @@ public abstract class BaseEnterSmsCodeFragment { callMeCountDown.setVisibility(View.VISIBLE); + resendSmsCountDown.setVisibility(View.VISIBLE); wrongNumber.setVisibility(View.VISIBLE); verificationCodeView.clear(); keyboard.displayKeyboard(); @@ -209,6 +224,7 @@ public abstract class BaseEnterSmsCodeFragment handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode.PHONE_CALL), + this::returnToPhoneEntryScreen); } - private void handlePhoneCallRequestAfterConfirm() { - Disposable request = viewModel.requestVerificationCode(VerifyAccountRepository.Mode.PHONE_CALL) + private void handleSmsRequest() { + showConfirmNumberDialogIfTranslated(requireContext(), + R.string.RegistrationActivity_a_verification_code_will_be_sent_to, + viewModel.getNumber().getE164Number(), + () -> handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode.SMS_WITH_LISTENER), + this::returnToPhoneEntryScreen); + } + + private void handleCodeCallRequestAfterConfirm(VerifyAccountRepository.Mode mode) { + MccMncProducer mccMncProducer = new MccMncProducer(requireContext()); + Disposable request = viewModel.requestVerificationCode(mode, mccMncProducer.getMcc(), mccMncProducer.getMnc()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(processor -> { if (processor.hasResult()) { - Toast.makeText(requireContext(), R.string.RegistrationActivity_call_requested, Toast.LENGTH_LONG).show(); + Toast.makeText(requireContext(), getCodeRequestedToastText(mode), Toast.LENGTH_LONG).show(); } else if (processor.captchaRequired()) { navigateToCaptcha(); } else if (processor.rateLimit()) { + long rateLimit = processor.getRateLimit(); Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show(); } else { Log.w(TAG, "Unable to request phone code", processor.getError()); @@ -308,6 +335,19 @@ public abstract class BaseEnterSmsCodeFragment { if (!autoCompleting) { @@ -323,10 +363,52 @@ public abstract class BaseEnterSmsCodeFragment callMeCountDown.startCountDownTo(callAtTime)); + MccMncProducer mccMncProducer = new MccMncProducer(requireContext()); + Disposable request = viewModel.validateSession(sessionE164, mccMncProducer.getMcc(), mccMncProducer.getMnc()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(processor -> { + if (!processor.hasResult()) { + returnToPhoneEntryScreen(); + } else if (processor.isInvalidSession()) { + returnToPhoneEntryScreen(); + } else if (processor.cannotSubmitVerificationAttempt()) { + returnToPhoneEntryScreen(); + } else if (!processor.canSubmitProofImmediately()) { + handleRateLimited(); + } + // else session state is valid and server is ready to accept code + }); + + disposables.add(request); + + viewModel.getCanCallAtTime().observe(getViewLifecycleOwner(), callAtTime -> { + if (callAtTime > 0) { + callMeCountDown.setVisibility(View.VISIBLE); + callMeCountDown.startCountDownTo(callAtTime); + } else { + callMeCountDown.setVisibility(View.INVISIBLE); + } + }); + viewModel.getCanSmsAtTime().observe(getViewLifecycleOwner(), smsAtTime -> { + if (smsAtTime > 0) { + resendSmsCountDown.setVisibility(View.VISIBLE); + resendSmsCountDown.startCountDownTo(smsAtTime); + } else { + resendSmsCountDown.setVisibility(View.INVISIBLE); + } + }); + } + + private void openTroubleshootingSteps() { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.support_center_url)); } private void sendEmailToSupport() { @@ -347,6 +429,6 @@ public abstract class BaseEnterSmsCodeFragment + // We check if there is an *URL* annotation attached to the text + // at the clicked position + annotatedText.getStringAnnotations( + tag = "URL", + start = offset, + end = offset + ) + .firstOrNull()?.let { annotation -> + when (annotation.item) { + TROUBLESHOOTING_STEPS_KEY -> troubleshootingStepsListener.run() + CONTACT_SUPPORT_KEY -> contactSupportListener.run() + } + } + }, + modifier = Modifier.padding(16.dp) + ) + } + } + companion object { + private const val TROUBLESHOOTING_STEPS_KEY = "troubleshooting" + private const val CONTACT_SUPPORT_KEY = "contact_support" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java index e7ce8d7cc6..3bae2b196b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java @@ -10,6 +10,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; import android.widget.ScrollView; import androidx.annotation.NonNull; @@ -49,9 +50,11 @@ import org.thoughtcrime.securesms.util.LifecycleDisposable; import org.thoughtcrime.securesms.util.PlayServicesUtil; import org.thoughtcrime.securesms.util.SupportEmailUtil; import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.dualsim.MccMncProducer; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton; +import java.util.Locale; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -153,7 +156,8 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R } private void handleRegister(@NonNull Context context) { - if (TextUtils.isEmpty(countryCode.getEditText().getText())) { + final EditText countryCodeEditText = countryCode.getEditText(); + if (countryCodeEditText == null || TextUtils.isEmpty(countryCodeEditText.getText())) { showErrorDialog(context, getString(R.string.RegistrationActivity_you_must_specify_your_country_code)); return; } @@ -245,23 +249,23 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R } private void requestVerificationCode(@NonNull Mode mode) { - NavController navController = NavHostFragment.findNavController(this); - - Disposable request = viewModel.requestVerificationCode(mode) + NavController navController = NavHostFragment.findNavController(this); + MccMncProducer mccMncProducer = new MccMncProducer(requireContext()); + Disposable request = viewModel.requestVerificationCode(mode, mccMncProducer.getMcc(), mccMncProducer.getMnc()) .doOnSubscribe(unused -> SignalStore.account().setRegistered(false)) .observeOn(AndroidSchedulers.mainThread()) .subscribe(processor -> { - if (processor.hasResult()) { - SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); - } else if (processor.localRateLimit()) { - Log.i(TAG, "Unable to request sms code due to local rate limit"); + if (processor.verificationCodeRequestSuccess()) { SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); } else if (processor.captchaRequired()) { Log.i(TAG, "Unable to request sms code due to captcha required"); SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionRequestCaptcha()); + } else if (processor.exhaustedVerificationCodeAttempts()) { + Log.i(TAG, "Unable to request sms code due to exhausting attempts"); + showErrorDialog(register.getContext(), getString(R.string.RegistrationActivity_rate_limited_to_service)); } else if (processor.rateLimit()) { Log.i(TAG, "Unable to request sms code due to rate limit"); - showErrorDialog(register.getContext(), getString(R.string.RegistrationActivity_rate_limited_to_service)); + showErrorDialog(register.getContext(), getString(R.string.RegistrationActivity_rate_limited_to_try_again, formatMillisecondsToString(processor.getRateLimit()))); } else if (processor.isImpossibleNumber()) { Log.w(TAG, "Impossible number", processor.getError()); Dialogs.showAlertDialog(requireContext(), @@ -281,6 +285,14 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R disposables.add(request); } + private String formatMillisecondsToString(long milliseconds) { + long totalSeconds = milliseconds / 1000; + long HH = totalSeconds / 3600; + long MM = (totalSeconds % 3600) / 60; + long SS = totalSeconds % 60; + return String.format(Locale.getDefault(), "%02d:%02d:%02d", HH, MM, SS); + } + public void showErrorDialog(Context context, String msg) { new MaterialAlertDialogBuilder(context).setMessage(msg).setPositiveButton(R.string.ok, null).show(); } @@ -306,6 +318,31 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R viewModel.onCountrySelected(null, countryCode); } + @Override + public void onStart() { + super.onStart(); + String sessionE164 = viewModel.getSessionE164(); + if (sessionE164 != null && viewModel.getSessionId() != null) { + checkIfSessionIsInProgressAndAdvance(sessionE164); + } + } + + private void checkIfSessionIsInProgressAndAdvance(@NonNull String sessionE164) { + NavController navController = NavHostFragment.findNavController(this); + MccMncProducer mccMncProducer = new MccMncProducer(requireContext()); + Disposable request = viewModel.validateSession(sessionE164, mccMncProducer.getMcc(), mccMncProducer.getMnc()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(processor -> { + if (processor.hasResult() && processor.canSubmitProofImmediately()) { + SafeNavigation.safeNavigate(navController, EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); + } else { + viewModel.resetSession(); + } + }); + + disposables.add(request); + } + private void handleNonNormalizedNumberError(@NonNull String originalNumber, @NonNull String normalizedNumber, @NonNull Mode mode) { try { Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null); @@ -346,9 +383,9 @@ public final class EnterPhoneNumberFragment extends LoggingFragment implements R .show(); } - protected final void confirmNumberPrompt(@NonNull Context context, - @NonNull String e164number, - @NonNull Runnable onConfirmed) + private void confirmNumberPrompt(@NonNull Context context, + @NonNull String e164number, + @NonNull Runnable onConfirmed) { showConfirmNumberDialogIfTranslated(context, R.string.RegistrationActivity_a_verification_code_will_be_sent_to, diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java index 89e4db1aa5..fb4d11b401 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/BaseRegistrationViewModel.java @@ -9,19 +9,18 @@ import androidx.lifecycle.ViewModel; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.pin.KbsRepository; import org.thoughtcrime.securesms.pin.TokenData; -import org.thoughtcrime.securesms.registration.RequestVerificationCodeResponseProcessor; +import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor; import org.thoughtcrime.securesms.registration.VerifyAccountRepository; import org.thoughtcrime.securesms.registration.VerifyAccountRepository.Mode; import org.thoughtcrime.securesms.registration.VerifyResponse; import org.thoughtcrime.securesms.registration.VerifyResponseProcessor; import org.thoughtcrime.securesms.registration.VerifyResponseWithFailedKbs; +import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor; import org.thoughtcrime.securesms.registration.VerifyResponseWithSuccessfulKbs; import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs; -import org.thoughtcrime.securesms.registration.VerifyResponseWithRegistrationLockProcessor; import org.whispersystems.signalservice.internal.ServiceResponse; import java.util.Objects; -import java.util.concurrent.TimeUnit; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; @@ -33,9 +32,6 @@ import io.reactivex.rxjava3.core.Single; */ public abstract class BaseRegistrationViewModel extends ViewModel { - private static final long FIRST_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(64); - private static final long SUBSEQUENT_CALL_AVAILABLE_AFTER_MS = TimeUnit.SECONDS.toMillis(300); - private static final String STATE_NUMBER = "NUMBER"; private static final String STATE_REGISTRATION_SECRET = "REGISTRATION_SECRET"; private static final String STATE_VERIFICATION_CODE = "TEXT_CODE_ENTERED"; @@ -45,6 +41,7 @@ public abstract class BaseRegistrationViewModel extends ViewModel { private static final String STATE_KBS_TOKEN = "KBS_TOKEN"; private static final String STATE_TIME_REMAINING = "TIME_REMAINING"; private static final String STATE_CAN_CALL_AT_TIME = "CAN_CALL_AT_TIME"; + private static final String STATE_CAN_SMS_AT_TIME = "CAN_SMS_AT_TIME"; protected final SavedStateHandle savedState; protected final VerifyAccountRepository verifyAccountRepository; @@ -73,6 +70,27 @@ public abstract class BaseRegistrationViewModel extends ViewModel { } } + public @Nullable String getSessionId() { + return SignalStore.registrationValues().getSessionId(); + } + + public void setSessionId(String sessionId) { + SignalStore.registrationValues().setSessionId(sessionId); + } + + public @Nullable String getSessionE164() { + return SignalStore.registrationValues().getSessionE164(); + } + + public void setSessionE164(String sessionE164) { + SignalStore.registrationValues().setSessionE164(sessionE164); + } + + public void resetSession() { + setSessionE164(null); + setSessionId(null); + } + public @NonNull NumberViewState getNumber() { //noinspection ConstantConditions return savedState.get(STATE_NUMBER); @@ -138,15 +156,6 @@ public abstract class BaseRegistrationViewModel extends ViewModel { return savedState.getLiveData(STATE_SUCCESSFUL_CODE_REQUEST_ATTEMPTS, 0); } - public @NonNull LocalCodeRequestRateLimiter getRequestLimiter() { - //noinspection ConstantConditions - return savedState.get(STATE_REQUEST_RATE_LIMITER); - } - - public void updateLimiter() { - savedState.set(STATE_REQUEST_RATE_LIMITER, savedState.get(STATE_REQUEST_RATE_LIMITER)); - } - public @Nullable TokenData getKeyBackupCurrentToken() { return savedState.get(STATE_KBS_TOKEN); } @@ -163,43 +172,118 @@ public abstract class BaseRegistrationViewModel extends ViewModel { return savedState.getLiveData(STATE_CAN_CALL_AT_TIME, 0L); } + public LiveData getCanSmsAtTime() { + return savedState.getLiveData(STATE_CAN_SMS_AT_TIME, 0L); + } + public void setLockedTimeRemaining(long lockedTimeRemaining) { savedState.set(STATE_TIME_REMAINING, lockedTimeRemaining); } - public void onStartEnterCode() { - savedState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + FIRST_CALL_AVAILABLE_AFTER_MS); + public void setCanCallAtTime(long callingTimestamp) { + savedState.getLiveData(STATE_CAN_CALL_AT_TIME).postValue(callingTimestamp); } - public void onCallRequested() { - savedState.set(STATE_CAN_CALL_AT_TIME, System.currentTimeMillis() + SUBSEQUENT_CALL_AVAILABLE_AFTER_MS); + public void setCanSmsAtTime(long smsTimestamp) { + savedState.getLiveData(STATE_CAN_SMS_AT_TIME).postValue(smsTimestamp); } - public Single requestVerificationCode(@NonNull Mode mode) { - String captcha = getCaptchaToken(); - clearCaptchaResponse(); + public Single requestVerificationCode(@NonNull Mode mode, @Nullable String mcc, @Nullable String mnc) { - if (mode == Mode.PHONE_CALL) { - onCallRequested(); - } else if (!getRequestLimiter().canRequest(mode, getNumber().getE164Number(), System.currentTimeMillis())) { - return Single.just(RequestVerificationCodeResponseProcessor.forLocalRateLimit()); + final String e164 = getNumber().getE164Number(); + + return getValidSession(e164, mcc, mnc) + .flatMap(processor -> { + if (!processor.hasResult()) { + return Single.just(processor); + } + + String sessionId = processor.getSessionId(); + setSessionId(sessionId); + setSessionE164(e164); + + return handleRequiredChallenges(processor, e164); + }) + .flatMap(processor -> { + if (!processor.hasResult()) { + return Single.just(processor); + } + + if (!processor.isAllowedToRequestCode()) { + return Single.just(processor); + } + + String sessionId = processor.getSessionId(); + clearCaptchaResponse(); + return verifyAccountRepository.requestVerificationCode(sessionId, + getNumber().getE164Number(), + getRegistrationSecret(), + mode) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new); + }) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSuccess((RegistrationSessionProcessor processor) -> { + if (processor.hasResult()) { + markASuccessfulAttempt(); + } + + if (processor.hasResult() && processor.isAllowedToRequestCode()) { + setCanSmsAtTime(processor.getNextCodeViaSmsAttempt()); + setCanCallAtTime(processor.getNextCodeViaCallAttempt()); + } + }); + } + + public Single validateSession(String e164, @Nullable String mcc, @Nullable String mnc) { + String storedSessionId = null; + if (e164.equals(getSessionE164())) { + storedSessionId = getSessionId(); + } + return verifyAccountRepository.validateSession(storedSessionId, e164, getRegistrationSecret()) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new); + } + + public Single getValidSession(String e164, @Nullable String mcc, @Nullable String mnc) { + return validateSession(e164, mcc, mnc) + .flatMap(processor -> { + if (processor.isInvalidSession()) { + return verifyAccountRepository.requestValidSession(e164, getRegistrationSecret(), mcc, mnc) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new); + } else { + return Single.just(processor); + } + }); + } + + public Single handleRequiredChallenges(RegistrationSessionProcessor processor, String e164) { + final String sessionId = processor.getSessionId(); + + if (processor.isAllowedToRequestCode()) { + return Single.just(processor); } - return verifyAccountRepository.requestVerificationCode(getNumber().getE164Number(), - getRegistrationSecret(), - mode, - captcha) - .map(RequestVerificationCodeResponseProcessor::new) - .observeOn(AndroidSchedulers.mainThread()) - .doOnSuccess(processor -> { - if (processor.hasResult()) { - markASuccessfulAttempt(); - getRequestLimiter().onSuccessfulRequest(mode, getNumber().getE164Number(), System.currentTimeMillis()); - } else { - getRequestLimiter().onUnsuccessfulRequest(); - } - updateLimiter(); - }); + if (hasCaptchaToken() && processor.captchaRequired()) { + return verifyAccountRepository.verifyCaptcha(sessionId, Objects.requireNonNull(getCaptchaToken()), e164, getRegistrationSecret()) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new); + } else { + String challenge = processor.getChallenge(); + if (challenge != null) { + switch (challenge) { + case RegistrationSessionProcessor.PUSH_CHALLENGE_KEY: + return verifyAccountRepository.requestAndVerifyPushToken(sessionId, + getNumber().getE164Number(), + getRegistrationSecret()) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForSession::new); + + case RegistrationSessionProcessor.CAPTCHA_KEY: + // fall through to passing the processor back so that the eventual subscriber will check captchaRequired() and handle accordingly + default: + break; + } + } + } + + return Single.just(processor); } public Single verifyCodeWithoutRegistrationLock(@NonNull String code) { @@ -261,4 +345,5 @@ public abstract class BaseRegistrationViewModel extends ViewModel { protected abstract Single onVerifySuccess(@NonNull VerifyResponseProcessor processor); protected abstract Single onVerifySuccessWithRegistrationLock(@NonNull VerifyResponseWithRegistrationLockProcessor processor, String pin); + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java index 2e254d183d..7945c414ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java @@ -14,7 +14,7 @@ import org.thoughtcrime.securesms.pin.KbsRepository; import org.thoughtcrime.securesms.pin.TokenData; import org.thoughtcrime.securesms.registration.RegistrationData; import org.thoughtcrime.securesms.registration.RegistrationRepository; -import org.thoughtcrime.securesms.registration.RequestVerificationCodeResponseProcessor; +import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor; import org.thoughtcrime.securesms.registration.VerifyAccountRepository; import org.thoughtcrime.securesms.registration.VerifyResponse; import org.thoughtcrime.securesms.registration.VerifyResponseProcessor; @@ -23,10 +23,13 @@ import org.thoughtcrime.securesms.registration.VerifyResponseWithoutKbs; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.KbsPinData; import org.whispersystems.signalservice.internal.ServiceResponse; +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse; import java.util.Objects; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; public final class RegistrationViewModel extends BaseRegistrationViewModel { @@ -96,43 +99,69 @@ public final class RegistrationViewModel extends BaseRegistrationViewModel { return completed != null ? completed : false; } - @Override - public Single requestVerificationCode(@NonNull VerifyAccountRepository.Mode mode) { - return super.requestVerificationCode(mode) - .doOnSuccess(processor -> { - if (processor.hasResult()) { - setFcmToken(processor.getResult().getFcmToken().orElse(null)); - } - }); - } - @Override protected Single> verifyAccountWithoutRegistrationLock() { - return verifyAccountRepository.verifyAccount(getRegistrationData()) - .flatMap(verifyAccountWithoutKbsResponse -> { - VerifyResponseProcessor processor = new VerifyResponseWithoutKbs(verifyAccountWithoutKbsResponse); - String pin = SignalStore.kbsValues().getPin(); + final String sessionId = getSessionId(); + if (sessionId == null) { + throw new IllegalStateException("No valid registration session"); + } + return verifyAccountRepository.verifyAccount(sessionId, getRegistrationData()) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSuccess(processor -> { + setCanSmsAtTime(processor.getNextCodeViaSmsAttempt()); + setCanCallAtTime(processor.getNextCodeViaCallAttempt()); + }) + .observeOn(Schedulers.io()) + .flatMap( processor -> { + if (processor.isAlreadyVerified() || (processor.hasResult() && processor.isVerified())) { + return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), null, null); + } else { + return Single.just(ServiceResponse.coerceError(processor.getResponse())); + } + }) + .flatMap(verifyAccountWithoutKbsResponse -> { + VerifyResponseProcessor processor = new VerifyResponseWithoutKbs(verifyAccountWithoutKbsResponse); + String pin = SignalStore.kbsValues().getPin(); - if (processor.registrationLock() && SignalStore.kbsValues().getRegistrationLockToken() != null && pin != null) { - KbsPinData pinData = new KbsPinData(SignalStore.kbsValues().getOrCreateMasterKey(), SignalStore.kbsValues().getRegistrationLockTokenResponse()); + if (processor.registrationLock() && SignalStore.kbsValues().getRegistrationLockToken() != null && pin != null) { + KbsPinData pinData = new KbsPinData(SignalStore.kbsValues().getOrCreateMasterKey(), SignalStore.kbsValues().getRegistrationLockTokenResponse()); - return verifyAccountRepository.verifyAccountWithPin(getRegistrationData(), pin, () -> pinData) - .map(verifyAccountWithPinResponse -> { - if (verifyAccountWithPinResponse.getResult().isPresent() && verifyAccountWithPinResponse.getResult().get().getKbsData() != null) { - return verifyAccountWithPinResponse; - } else { - return verifyAccountWithoutKbsResponse; - } - }); - } else { - return Single.just(verifyAccountWithoutKbsResponse); - } - }); + return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), pin, () -> pinData) + .map(verifyAccountWithPinResponse -> { + if (verifyAccountWithPinResponse.getResult().isPresent() && verifyAccountWithPinResponse.getResult().get().getKbsData() != null) { + return verifyAccountWithPinResponse; + } else { + return verifyAccountWithoutKbsResponse; + } + }); + } else { + return Single.just(verifyAccountWithoutKbsResponse); + } + }); } @Override protected Single> verifyAccountWithRegistrationLock(@NonNull String pin, @NonNull TokenData kbsTokenData) { - return verifyAccountRepository.verifyAccountWithPin(getRegistrationData(), pin, () -> Objects.requireNonNull(KbsRepository.restoreMasterKey(pin, kbsTokenData.getEnclave(), kbsTokenData.getBasicAuth(), kbsTokenData.getTokenResponse()))); + final String sessionId = getSessionId(); + if (sessionId == null) { + throw new IllegalStateException("No valid registration session"); + } + return verifyAccountRepository.verifyAccount(sessionId, getRegistrationData()) + .map(RegistrationSessionProcessor.RegistrationSessionProcessorForVerification::new) + .doOnSuccess(processor -> { + if (processor.hasResult()) { + setCanSmsAtTime(processor.getNextCodeViaSmsAttempt()); + setCanCallAtTime(processor.getNextCodeViaCallAttempt()); + } + }) + .flatMap( processor -> { + if (processor.isAlreadyVerified() || (processor.hasResult() && processor.isVerified())) { + return verifyAccountRepository.registerAccount(sessionId, getRegistrationData(), pin, () -> Objects.requireNonNull(KbsRepository.restoreMasterKey(pin, kbsTokenData.getEnclave(), kbsTokenData.getBasicAuth(), kbsTokenData.getTokenResponse()))); + } else { + return Single.just(ServiceResponse.coerceError(processor.getResponse())); + } + }); } @Override @@ -154,7 +183,8 @@ public final class RegistrationViewModel extends BaseRegistrationViewModel { registrationRepository.getRegistrationId(), registrationRepository.getProfileKey(getNumber().getE164Number()), getFcmToken(), - registrationRepository.getPniRegistrationId()); + registrationRepository.getPniRegistrationId(), + registrationRepository.getRecoveryPassword()); } public static final class Factory extends AbstractSavedStateViewModelFactory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/MccMncProducer.kt b/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/MccMncProducer.kt new file mode 100644 index 0000000000..f4e484154b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/dualsim/MccMncProducer.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.util.dualsim + +import android.content.Context +import android.content.pm.PackageManager.FEATURE_TELEPHONY_RADIO_ACCESS +import android.telephony.TelephonyManager +import androidx.core.content.ContextCompat + +/** + * The mobile country code consists of three decimal digits and the mobile network code consists of two or three decimal digits. + */ +class MccMncProducer(context: Context) { + var mcc: String? = null + private set + var mnc: String? = null + private set + + init { + if (context.packageManager.hasSystemFeature(FEATURE_TELEPHONY_RADIO_ACCESS)) { + val tel = ContextCompat.getSystemService(context, TelephonyManager::class.java) + val networkOperator = tel?.networkOperator + + if (networkOperator?.isNotBlank() == true && networkOperator.length >= 5) { + mcc = networkOperator.substring(0, 3) + mnc = networkOperator.substring(3) + } + } + } +} diff --git a/app/src/main/res/layout/fragment_registration_captcha.xml b/app/src/main/res/layout/fragment_registration_captcha.xml index 6e4cbf78ea..26cffc8a82 100644 --- a/app/src/main/res/layout/fragment_registration_captcha.xml +++ b/app/src/main/res/layout/fragment_registration_captcha.xml @@ -8,6 +8,7 @@ android:fillViewport="true"> @@ -21,6 +22,7 @@ android:layout_marginEnd="24dp" android:gravity="center" android:text="@string/RegistrationActivity_we_need_to_verify_that_youre_human" + android:textColor="@color/signal_colorOnSurface" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/fragment_registration_lock.xml b/app/src/main/res/layout/fragment_registration_lock.xml index 94e651fb33..9f130a3f3f 100644 --- a/app/src/main/res/layout/fragment_registration_lock.xml +++ b/app/src/main/res/layout/fragment_registration_lock.xml @@ -45,6 +45,7 @@ android:gravity="center_horizontal" android:inputType="numberPassword" android:minWidth="210dp" + android:textColor="@color/signal_colorOnSurface" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/kbs_lock_pin_description" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d97d6705f3..6209c5320a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1782,11 +1782,18 @@ Signal needs the contacts and media permissions to help you connect with friends and send messages. Your contacts are uploaded using Signal\'s private contact discovery, which means they are end-to-end encrypted and never visible to the Signal service. Signal needs the contacts permission to help you connect with friends. Your contacts are uploaded using Signal\'s private contact discovery, which means they are end-to-end encrypted and never visible to the Signal service. You\'ve made too many attempts to register this number. Please try again later. + + You\'ve made too many attempts to register this number. Please try again in %s. Unable to connect to service. Please check network connection and try again. Non-standard number format The number you entered (%1$s) appears to be a non-standard format.\n\nDid you mean %2$s? Signal Android - Phone Number Format + Call requested + + SMS requested + + Verification code requested You are now %d step away from submitting a debug log. You are now %d steps away from submitting a debug log. @@ -1806,6 +1813,11 @@ Call Verification Code Resend Code + Having trouble registering? + • Make sure your phone has a cellular signal to receive your SMS or call\n • Confirm you can receive a phone call to the number\n • Check that you have entered your phone number correctly.\nFor more information, please follow + these troubleshooting steps + or + Contact Support Turn on Registration Lock? @@ -3417,7 +3429,10 @@ Your backup contains a very large file that cannot be backed up. Please delete it and create a new backup. Tap to manage backups. Wrong number? + Call me (%1$02d:%2$02d) + + Resend Code (%1$02d:%2$02d) Contact Signal Support Signal Registration - Verification Code for Android Incorrect code diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java b/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java index 05443cd7d7..78ff7f1a2e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/registration/PushChallengeRequestTest.java @@ -34,7 +34,7 @@ public final class PushChallengeRequestTest { public void getPushChallengeBlocking_returns_absent_if_times_out() { SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); - Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 50L); + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, "session ID", Optional.of("token"), 50L); assertFalse(challenge.isPresent()); } @@ -44,7 +44,7 @@ public final class PushChallengeRequestTest { SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); long startTime = System.currentTimeMillis(); - PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 250L); + PushChallengeRequest.getPushChallengeBlocking(signal, "session ID", Optional.of("token"), 250L); long duration = System.currentTimeMillis() - startTime; assertThat(duration, greaterThanOrEqualTo(250L)); @@ -56,14 +56,14 @@ public final class PushChallengeRequestTest { doAnswer(invocation -> { AsyncTask.execute(() -> PushChallengeRequest.postChallengeResponse("CHALLENGE")); return null; - }).when(signal).requestRegistrationPushChallenge("token", "+123456"); + }).when(signal).requestRegistrationPushChallenge("session ID", "token"); long startTime = System.currentTimeMillis(); - Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L); + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, "session ID", Optional.of("token"), 500L); long duration = System.currentTimeMillis() - startTime; assertThat(duration, lessThan(500L)); - verify(signal).requestRegistrationPushChallenge("token", "+123456"); + verify(signal).requestRegistrationPushChallenge("session ID", "token"); verifyNoMoreInteractions(signal); assertTrue(challenge.isPresent()); @@ -75,7 +75,7 @@ public final class PushChallengeRequestTest { SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); long startTime = System.currentTimeMillis(); - PushChallengeRequest.getPushChallengeBlocking(signal, Optional.empty(), "+123456", 500L); + PushChallengeRequest.getPushChallengeBlocking(signal, "session ID", Optional.empty(), 500L); long duration = System.currentTimeMillis() - startTime; assertThat(duration, lessThan(500L)); @@ -85,7 +85,7 @@ public final class PushChallengeRequestTest { public void getPushChallengeBlocking_returns_absent_if_no_fcm_token_supplied() { SignalServiceAccountManager signal = mock(SignalServiceAccountManager.class); - Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.empty(), "+123456", 500L); + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, "session ID", Optional.empty(), 500L); verifyNoInteractions(signal); assertFalse(challenge.isPresent()); @@ -97,7 +97,7 @@ public final class PushChallengeRequestTest { doThrow(new IOException()).when(signal).requestRegistrationPushChallenge(any(), any()); - Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, Optional.of("token"), "+123456", 500L); + Optional challenge = PushChallengeRequest.getPushChallengeBlocking(signal, "session ID", Optional.of("token"), 500L); assertFalse(challenge.isPresent()); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 5a058dab41..0c950c292a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -20,6 +20,7 @@ import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.whispersystems.signalservice.api.account.AccountAttributes; import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse; import org.whispersystems.signalservice.api.crypto.ProfileCipher; import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream; import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; @@ -28,7 +29,6 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.kbs.MasterKey; import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; -import org.whispersystems.signalservice.api.messages.multidevice.VerifyDeviceResponse; import org.whispersystems.signalservice.api.payments.CurrencyConversions; import org.whispersystems.signalservice.api.profiles.AvatarUploadParams; import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; @@ -60,7 +60,6 @@ import org.whispersystems.signalservice.internal.push.CdsiAuthResponse; import org.whispersystems.signalservice.internal.push.ProfileAvatarData; import org.whispersystems.signalservice.internal.push.PushServiceSocket; import org.whispersystems.signalservice.internal.push.RemoteConfigResponse; -import org.whispersystems.signalservice.internal.push.RequestVerificationCodeResponse; import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.VerifyAccountResponse; @@ -96,6 +95,7 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import io.reactivex.rxjava3.core.Single; @@ -211,11 +211,47 @@ public class SignalServiceAccountManager { * during SMS/call requests to bypass the CAPTCHA. * * @param gcmRegistrationId The GCM (FCM) id to use. - * @param e164number The number to associate it with. + * @param sessionId The session to request a push for. * @throws IOException */ - public void requestRegistrationPushChallenge(String gcmRegistrationId, String e164number) throws IOException { - this.pushServiceSocket.requestPushChallenge(gcmRegistrationId, e164number); + public void requestRegistrationPushChallenge(String sessionId, String gcmRegistrationId) throws IOException { + pushServiceSocket.requestPushChallenge(sessionId, gcmRegistrationId); + } + + public ServiceResponse createRegistrationSession(@Nullable String fcmToken, @Nullable String mcc, @Nullable String mnc) { + try { + final RegistrationSessionMetadataResponse response = pushServiceSocket.createVerificationSession(fcmToken, mcc, mnc); + return ServiceResponse.forResult(response, 200, null); + } catch (IOException e) { + return ServiceResponse.forUnknownError(e); + } + } + + public ServiceResponse getRegistrationSession(String sessionId) { + try { + final RegistrationSessionMetadataResponse response = pushServiceSocket.getSessionStatus(sessionId); + return ServiceResponse.forResult(response, 200, null); + } catch (IOException e) { + return ServiceResponse.forUnknownError(e); + } + } + + public ServiceResponse submitPushChallengeToken(String sessionId, String pushChallengeToken) { + try { + final RegistrationSessionMetadataResponse response = pushServiceSocket.patchVerificationSession(sessionId, null, null, null, null, pushChallengeToken); + return ServiceResponse.forResult(response, 200, null); + } catch (IOException e) { + return ServiceResponse.forUnknownError(e); + } + } + + public ServiceResponse submitCaptchaToken(String sessionId, @Nullable String captchaToken) { + try { + final RegistrationSessionMetadataResponse response = pushServiceSocket.patchVerificationSession(sessionId, null, null, null, captchaToken, null); + return ServiceResponse.forResult(response, 200, null); + } catch (IOException e) { + return ServiceResponse.forUnknownError(e); + } } /** @@ -223,13 +259,11 @@ public class SignalServiceAccountManager { * an SMS verification code to this Signal user. * * @param androidSmsRetrieverSupported - * @param captchaToken If the user has done a CAPTCHA, include this. - * @param challenge If present, it can bypass the CAPTCHA. */ - public ServiceResponse requestSmsVerificationCode(Locale locale, boolean androidSmsRetrieverSupported, Optional captchaToken, Optional challenge, Optional fcmToken) { + public ServiceResponse requestSmsVerificationCode(String sessionId, Locale locale, boolean androidSmsRetrieverSupported) { try { - this.pushServiceSocket.requestSmsVerificationCode(locale, androidSmsRetrieverSupported, captchaToken, challenge); - return ServiceResponse.forResult(new RequestVerificationCodeResponse(fcmToken), 200, null); + final RegistrationSessionMetadataResponse response = pushServiceSocket.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, PushServiceSocket.VerificationCodeTransport.SMS); + return ServiceResponse.forResult(response, 200, null); } catch (IOException e) { return ServiceResponse.forUnknownError(e); } @@ -240,13 +274,11 @@ public class SignalServiceAccountManager { * make a voice call to this Signal user. * * @param locale - * @param captchaToken If the user has done a CAPTCHA, include this. - * @param challenge If present, it can bypass the CAPTCHA. */ - public ServiceResponse requestVoiceVerificationCode(Locale locale, Optional captchaToken, Optional challenge, Optional fcmToken) { + public ServiceResponse requestVoiceVerificationCode(String sessionId, Locale locale, boolean androidSmsRetrieverSupported) { try { - this.pushServiceSocket.requestVoiceVerificationCode(locale, captchaToken, challenge); - return ServiceResponse.forResult(new RequestVerificationCodeResponse(fcmToken), 200, null); + final RegistrationSessionMetadataResponse response = pushServiceSocket.requestVerificationCode(sessionId, locale, androidSmsRetrieverSupported, PushServiceSocket.VerificationCodeTransport.VOICE); + return ServiceResponse.forResult(response, 200, null); } catch (IOException e) { return ServiceResponse.forUnknownError(e); } @@ -258,110 +290,30 @@ public class SignalServiceAccountManager { * @param verificationCode The verification code received via SMS or Voice * (see {@link #requestSmsVerificationCode} and * {@link #requestVoiceVerificationCode}). - * @param signalProtocolRegistrationId A random 14-bit number that identifies this Signal install. - * This value should remain consistent across registrations for the - * same install, but probabilistically differ across registrations - * for separate installs. + * @param sessionId The ID of the current registration session. * @return The UUID of the user that was registered. * @throws IOException for various HTTP and networking errors */ - public ServiceResponse verifyAccount(String verificationCode, - int signalProtocolRegistrationId, - boolean fetchesMessages, - byte[] unidentifiedAccessKey, - boolean unrestrictedUnidentifiedAccess, - AccountAttributes.Capabilities capabilities, - boolean discoverableByPhoneNumber, - int pniRegistrationId) + public ServiceResponse verifyAccount(String verificationCode, + String sessionId) { try { - VerifyAccountResponse response = this.pushServiceSocket.verifyAccountCode(verificationCode, - null, - signalProtocolRegistrationId, - fetchesMessages, - null, - null, - unidentifiedAccessKey, - unrestrictedUnidentifiedAccess, - capabilities, - discoverableByPhoneNumber, - pniRegistrationId); + RegistrationSessionMetadataResponse response = pushServiceSocket.submitVerificationCode(sessionId, verificationCode); return ServiceResponse.forResult(response, 200, null); } catch (IOException e) { return ServiceResponse.forUnknownError(e); } } - /** - * Verify a Signal Service account with a received SMS or voice verification code with - * registration lock. - * - * @param verificationCode The verification code received via SMS or Voice - * (see {@link #requestSmsVerificationCode} and - * {@link #requestVoiceVerificationCode}). - * @param signalProtocolRegistrationId A random 14-bit number that identifies this Signal install. - * This value should remain consistent across registrations for the - * same install, but probabilistically differ across registrations - * for separate installs. - * @param registrationLock Only supply if found on KBS. - * @return The UUID of the user that was registered. - */ - public ServiceResponse verifyAccountWithRegistrationLockPin(String verificationCode, - int signalProtocolRegistrationId, - boolean fetchesMessages, - String registrationLock, - byte[] unidentifiedAccessKey, - boolean unrestrictedUnidentifiedAccess, - AccountAttributes.Capabilities capabilities, - boolean discoverableByPhoneNumber, - int pniRegistrationId) - { + public @Nonnull ServiceResponse registerAccount(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, boolean skipDeviceTransfer) { try { - VerifyAccountResponse response = this.pushServiceSocket.verifyAccountCode(verificationCode, - null, - signalProtocolRegistrationId, - fetchesMessages, - null, - registrationLock, - unidentifiedAccessKey, - unrestrictedUnidentifiedAccess, - capabilities, - discoverableByPhoneNumber, - pniRegistrationId); + VerifyAccountResponse response = pushServiceSocket.submitRegistrationRequest(sessionId, recoveryPassword, attributes, skipDeviceTransfer); return ServiceResponse.forResult(response, 200, null); } catch (IOException e) { return ServiceResponse.forUnknownError(e); } } - public VerifyDeviceResponse verifySecondaryDevice(String verificationCode, - int signalProtocolRegistrationId, - boolean fetchesMessages, - byte[] unidentifiedAccessKey, - boolean unrestrictedUnidentifiedAccess, - AccountAttributes.Capabilities capabilities, - boolean discoverableByPhoneNumber, - byte[] encryptedDeviceName, - int pniRegistrationId) - throws IOException - { - AccountAttributes accountAttributes = new AccountAttributes( - null, - signalProtocolRegistrationId, - fetchesMessages, - null, - null, - unidentifiedAccessKey, - unrestrictedUnidentifiedAccess, - capabilities, - discoverableByPhoneNumber, - Base64.encodeBytes(encryptedDeviceName), - pniRegistrationId - ); - - return this.pushServiceSocket.verifySecondaryDevice(verificationCode, accountAttributes); - } - public @Nonnull ServiceResponse changeNumber(@Nonnull ChangePhoneNumberRequest changePhoneNumberRequest) { try { VerifyAccountResponse response = this.pushServiceSocket.changeNumber(changePhoneNumberRequest); @@ -394,7 +346,8 @@ public class SignalServiceAccountManager { AccountAttributes.Capabilities capabilities, boolean discoverableByPhoneNumber, byte[] encryptedDeviceName, - int pniRegistrationId) + int pniRegistrationId, + String recoveryPassword) throws IOException { this.pushServiceSocket.setAccountAttributes( @@ -408,7 +361,8 @@ public class SignalServiceAccountManager { capabilities, discoverableByPhoneNumber, encryptedDeviceName, - pniRegistrationId + pniRegistrationId, + recoveryPassword ); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt index 550dee8bba..740a785e00 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt @@ -24,6 +24,7 @@ class AccountAttributes @JsonCreator constructor( @JsonProperty val capabilities: Capabilities?, @JsonProperty val name: String?, @JsonProperty val pniRegistrationId: Int, + @JsonProperty val recoveryPassword: String?, ) { constructor( signalingKey: String?, @@ -36,7 +37,8 @@ class AccountAttributes @JsonCreator constructor( capabilities: Capabilities?, isDiscoverableByPhoneNumber: Boolean, name: String?, - pniRegistrationId: Int + pniRegistrationId: Int, + recoveryPassword: String? ) : this( signalingKey = signalingKey, registrationId = registrationId, @@ -50,7 +52,8 @@ class AccountAttributes @JsonCreator constructor( isDiscoverableByPhoneNumber = isDiscoverableByPhoneNumber, capabilities = capabilities, name = name, - pniRegistrationId = pniRegistrationId + pniRegistrationId = pniRegistrationId, + recoveryPassword = recoveryPassword ) data class Capabilities @JsonCreator constructor( diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java index 1b36ffe9a8..e6acbad95a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java @@ -31,7 +31,7 @@ public final class MasterKey { return Hex.toStringCondensed(derive("Registration Lock")); } - public String deriveRegistrationRecoveryToken() { + public String deriveRegistrationRecoveryPassword() { return Hex.toStringCondensed(derive("Registration Recovery")); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/AlreadyVerifiedException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/AlreadyVerifiedException.kt new file mode 100644 index 0000000000..cbe35eedf3 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/AlreadyVerifiedException.kt @@ -0,0 +1,3 @@ +package org.whispersystems.signalservice.api.push.exceptions + +class AlreadyVerifiedException : NonSuccessfulResponseCodeException(409) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ExternalServiceFailureException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ExternalServiceFailureException.kt new file mode 100644 index 0000000000..98dceb0200 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/ExternalServiceFailureException.kt @@ -0,0 +1,9 @@ +package org.whispersystems.signalservice.api.push.exceptions + +/** + * known possible values for @property[reason]: + * providerRejected - indicates that the provider understood the request, but declined to deliver a verification SMS/call (potentially due to fraud prevention rules) + * providerUnavailable - indicates that the provider could not be reached or did not respond to the request to send a verification code in a timely manner + * illegalArgument - some part of the request was not understood or accepted by the provider (e.g. the provider did not recognize the phone number as a valid number for the selected transport) + */ +class ExternalServiceFailureException(val isPermanent: Boolean, val reason: String) : NonSuccessfulResponseCodeException(502) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/HttpConflictException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/HttpConflictException.kt new file mode 100644 index 0000000000..e163a86e96 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/HttpConflictException.kt @@ -0,0 +1,3 @@ +package org.whispersystems.signalservice.api.push.exceptions + +class HttpConflictException : NonSuccessfulResponseCodeException(409) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/InvalidTransportModeException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/InvalidTransportModeException.kt new file mode 100644 index 0000000000..fbb9207603 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/InvalidTransportModeException.kt @@ -0,0 +1,3 @@ +package org.whispersystems.signalservice.api.push.exceptions + +class InvalidTransportModeException : NonSuccessfulResponseCodeException(400) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/MustRequestNewCodeException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/MustRequestNewCodeException.kt new file mode 100644 index 0000000000..7a2141acef --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/MustRequestNewCodeException.kt @@ -0,0 +1,3 @@ +package org.whispersystems.signalservice.api.push.exceptions + +class MustRequestNewCodeException : NonSuccessfulResponseCodeException(409) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NoSuchSessionException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NoSuchSessionException.kt new file mode 100644 index 0000000000..205bb6da3f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NoSuchSessionException.kt @@ -0,0 +1,3 @@ +package org.whispersystems.signalservice.api.push.exceptions + +class NoSuchSessionException : NonSuccessfulResponseCodeException(404) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/PushChallengeRequiredException.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/PushChallengeRequiredException.kt new file mode 100644 index 0000000000..343116afd8 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/PushChallengeRequiredException.kt @@ -0,0 +1,3 @@ +package org.whispersystems.signalservice.api.push.exceptions + +class PushChallengeRequiredException : NonSuccessfulResponseCodeException(409) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index be7175b174..b8454544f2 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -59,21 +59,26 @@ import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; +import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ConflictException; import org.whispersystems.signalservice.api.push.exceptions.ContactManifestMismatchException; import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedException; -import org.whispersystems.signalservice.api.push.exceptions.ImpossiblePhoneNumberException; +import org.whispersystems.signalservice.api.push.exceptions.ExternalServiceFailureException; +import org.whispersystems.signalservice.api.push.exceptions.HttpConflictException; +import org.whispersystems.signalservice.api.push.exceptions.InvalidTransportModeException; import org.whispersystems.signalservice.api.push.exceptions.MalformedResponseException; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; +import org.whispersystems.signalservice.api.push.exceptions.MustRequestNewCodeException; import org.whispersystems.signalservice.api.push.exceptions.NoContentException; -import org.whispersystems.signalservice.api.push.exceptions.NonNormalizedPhoneNumberException; +import org.whispersystems.signalservice.api.push.exceptions.NoSuchSessionException; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResumableUploadResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; +import org.whispersystems.signalservice.api.push.exceptions.PushChallengeRequiredException; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.push.exceptions.RangeException; import org.whispersystems.signalservice.api.push.exceptions.RateLimitException; @@ -161,6 +166,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Function; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; @@ -191,15 +197,12 @@ public class PushServiceSocket { private static final String TAG = PushServiceSocket.class.getSimpleName(); - private static final String CREATE_ACCOUNT_SMS_PATH = "/v1/accounts/sms/code/%s?client=%s"; - private static final String CREATE_ACCOUNT_VOICE_PATH = "/v1/accounts/voice/code/%s"; private static final String VERIFY_ACCOUNT_CODE_PATH = "/v1/accounts/code/%s"; private static final String REGISTER_GCM_PATH = "/v1/accounts/gcm/"; private static final String TURN_SERVER_INFO = "/v1/accounts/turn"; private static final String SET_ACCOUNT_ATTRIBUTES = "/v1/accounts/attributes/"; private static final String PIN_PATH = "/v1/accounts/pin/"; private static final String REGISTRATION_LOCK_PATH = "/v1/accounts/registration_lock"; - private static final String REQUEST_PUSH_CHALLENGE = "/v1/accounts/fcm/preauth/%s/%s"; private static final String WHO_AM_I = "/v1/accounts/whoami"; private static final String GET_USERNAME_PATH = "/v1/accounts/username_hash/%s"; private static final String MODIFY_USERNAME_PATH = "/v1/accounts/username_hash"; @@ -235,6 +238,7 @@ public class PushServiceSocket { private static final String SENDER_CERTIFICATE_NO_E164_PATH = "/v1/certificate/delivery?includeE164=false"; private static final String KBS_AUTH_PATH = "/v1/backup/auth"; + private static final String KBS_AUTH_CHECK_PATH = "/v1/backup/auth/check"; private static final String ATTACHMENT_KEY_DOWNLOAD_PATH = "attachments/%s"; private static final String ATTACHMENT_ID_DOWNLOAD_PATH = "attachments/%d"; @@ -273,6 +277,11 @@ public class PushServiceSocket { private static final String BOOST_RECEIPT_CREDENTIALS = "/v1/subscription/boost/receipt_credentials"; private static final String DONATIONS_CONFIGURATION = "/v1/subscription/configuration"; + private static final String VERIFICATION_SESSION_PATH = "/v1/verification/session"; + private static final String VERIFICATION_CODE_PATH = "/v1/verification/session/%s/code"; + + private static final String REGISTRATION_PATH = "/v1/registration"; + private static final String CDSI_AUTH = "/v2/directory/auth"; private static final String REPORT_SPAM = "/v1/messages/report/%s/%s"; @@ -317,30 +326,79 @@ public class PushServiceSocket { this.clientZkProfileOperations = clientZkProfileOperations; } - public void requestSmsVerificationCode(Locale locale, boolean androidSmsRetriever, Optional captchaToken, Optional challenge) throws IOException { - Map headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS; - String path = String.format(CREATE_ACCOUNT_SMS_PATH, credentialsProvider.getE164(), androidSmsRetriever ? "android-2021-03" : "android"); - - if (captchaToken.isPresent()) { - path += "&captcha=" + captchaToken.get(); - } else if (challenge.isPresent()) { - path += "&challenge=" + challenge.get(); + public RegistrationSessionMetadataResponse createVerificationSession(@Nullable String pushToken, @Nullable String mcc, @Nullable String mnc) throws IOException { + final String jsonBody = JsonUtil.toJson(new VerificationSessionMetadataRequestBody(credentialsProvider.getE164(), pushToken, mcc, mnc)); + try (Response response = makeServiceRequest(VERIFICATION_SESSION_PATH, "POST", jsonRequestBody(jsonBody), NO_HEADERS, new RegistrationSessionResponseHandler(), Optional.empty(), false)) { + return parseSessionMetadataResponse(response); } - - makeServiceRequest(path, "GET", null, headers, new VerificationCodeResponseHandler(), Optional.empty()); } - public void requestVoiceVerificationCode(Locale locale, Optional captchaToken, Optional challenge) throws IOException { - Map headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS; - String path = String.format(CREATE_ACCOUNT_VOICE_PATH, credentialsProvider.getE164()); + public RegistrationSessionMetadataResponse getSessionStatus(String sessionId) throws IOException { + String path = VERIFICATION_SESSION_PATH + "/" + sessionId; - if (captchaToken.isPresent()) { - path += "?captcha=" + captchaToken.get(); - } else if (challenge.isPresent()) { - path += "?challenge=" + challenge.get(); + try (Response response = makeServiceRequest(path, "GET", jsonRequestBody(null), NO_HEADERS, new RegistrationSessionResponseHandler(), Optional.empty(), false)) { + return parseSessionMetadataResponse(response); + } + } + + public RegistrationSessionMetadataResponse patchVerificationSession(String sessionId, @Nullable String pushToken, @Nullable String mcc, @Nullable String mnc, @Nullable String captchaToken, @Nullable String pushChallengeToken) throws IOException { + String path = VERIFICATION_SESSION_PATH + "/" + sessionId; + + final UpdateVerificationSessionRequestBody requestBody = new UpdateVerificationSessionRequestBody(captchaToken, pushToken, pushChallengeToken, mcc, mnc); + try (Response response = makeServiceRequest(path, "PATCH", jsonRequestBody(JsonUtil.toJson(requestBody)), NO_HEADERS, new RegistrationSessionResponseHandler(), Optional.empty(), false)) { + return parseSessionMetadataResponse(response); + } + } + + public RegistrationSessionMetadataResponse requestVerificationCode(String sessionId, Locale locale, boolean androidSmsRetriever, VerificationCodeTransport transport) throws IOException { + String path = String.format(VERIFICATION_CODE_PATH, sessionId); + Map headers = locale != null ? Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()) : NO_HEADERS; + Map body = new HashMap<>(); + + switch (transport) { + case SMS: + body.put("transport", "sms"); + break; + case VOICE: + body.put("transport", "voice"); + break; } - makeServiceRequest(path, "GET", null, headers, new VerificationCodeResponseHandler(), Optional.empty()); + body.put("client", androidSmsRetriever ? "android-2021-03" : "android"); + + try (Response response = makeServiceRequest(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), headers, new RegistrationSessionResponseHandler(), Optional.empty(), false)) { + return parseSessionMetadataResponse(response); + } + } + + public RegistrationSessionMetadataResponse submitVerificationCode(String sessionId, String verificationCode) throws IOException { + String path = String.format(VERIFICATION_CODE_PATH, sessionId); + Map body = new HashMap<>(); + body.put("code", verificationCode); + try (Response response = makeServiceRequest(path, "PUT", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, new RegistrationCodeRequestResponseHandler(), Optional.empty(), false)) { + return parseSessionMetadataResponse(response); + } + } + + public VerifyAccountResponse submitRegistrationRequest(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, boolean skipDeviceTransfer) throws IOException { + String path = REGISTRATION_PATH; + if (sessionId == null && recoveryPassword == null) { + throw new IllegalArgumentException("Neither Session ID nor Recovery Password provided."); + + } + + if (sessionId != null && recoveryPassword != null) { + throw new IllegalArgumentException("You must supply one and only one of either: Session ID, or Recovery Password."); + } + RegistrationSessionRequestBody body; + if (sessionId != null) { + body = new RegistrationSessionRequestBody(sessionId, null, attributes, skipDeviceTransfer); + } else { + body = new RegistrationSessionRequestBody(null, recoveryPassword, attributes, skipDeviceTransfer); + } + + String response = makeServiceRequest(path, "POST", JsonUtil.toJson(body), NO_HEADERS, new RegistrationSessionResponseHandler(), Optional.empty()); + return JsonUtil.fromJson(response, VerifyAccountResponse.class); } public WhoAmIResponse getWhoAmI() throws IOException { @@ -361,26 +419,6 @@ public class PushServiceSocket { return JsonUtil.fromJsonResponse(body, CdsiAuthResponse.class); } - public VerifyAccountResponse verifyAccountCode(String verificationCode, - String signalingKey, - int registrationId, - boolean fetchesMessages, - String pin, - String registrationLock, - byte[] unidentifiedAccessKey, - boolean unrestrictedUnidentifiedAccess, - AccountAttributes.Capabilities capabilities, - boolean discoverableByPhoneNumber, - int pniRegistrationId) - throws IOException - { - AccountAttributes signalingKeyEntity = new AccountAttributes(signalingKey, registrationId, fetchesMessages, pin, registrationLock, unidentifiedAccessKey, unrestrictedUnidentifiedAccess, capabilities, discoverableByPhoneNumber, null, pniRegistrationId); - String requestBody = JsonUtil.toJson(signalingKeyEntity); - String responseBody = makeServiceRequest(String.format(VERIFY_ACCOUNT_CODE_PATH, verificationCode), "PUT", requestBody); - - return JsonUtil.fromJson(responseBody, VerifyAccountResponse.class); - } - public VerifyAccountResponse changeNumber(@Nonnull ChangePhoneNumberRequest changePhoneNumberRequest) throws IOException { @@ -400,7 +438,8 @@ public class PushServiceSocket { AccountAttributes.Capabilities capabilities, boolean discoverableByPhoneNumber, byte[] encryptedDeviceName, - int pniRegistrationId) + int pniRegistrationId, + String recoveryPassword) throws IOException { if (registrationLock != null && pin != null) { @@ -419,7 +458,8 @@ public class PushServiceSocket { capabilities, discoverableByPhoneNumber, name, - pniRegistrationId); + pniRegistrationId, + recoveryPassword); makeServiceRequest(SET_ACCOUNT_ATTRIBUTES, "PUT", JsonUtil.toJson(accountAttributes)); } @@ -429,11 +469,6 @@ public class PushServiceSocket { return JsonUtil.fromJson(responseText, DeviceCode.class).getVerificationCode(); } - public VerifyDeviceResponse verifySecondaryDevice(String verificationCode, AccountAttributes accountAttributes) throws IOException { - String responseText = makeServiceRequest(String.format(DEVICE_PATH, verificationCode), "PUT", JsonUtil.toJson(accountAttributes)); - return JsonUtil.fromJson(responseText, VerifyDeviceResponse.class); - } - public List getDevices() throws IOException { String responseText = makeServiceRequest(String.format(DEVICE_PATH, ""), "GET", null); return JsonUtil.fromJson(responseText, DeviceInfoList.class).getDevices(); @@ -457,8 +492,8 @@ public class PushServiceSocket { makeServiceRequest(REGISTER_GCM_PATH, "DELETE", null); } - public void requestPushChallenge(String gcmRegistrationId, String e164number) throws IOException { - makeServiceRequest(String.format(Locale.US, REQUEST_PUSH_CHALLENGE, gcmRegistrationId, e164number), "GET", null); + public void requestPushChallenge(String sessionId, String gcmRegistrationId) throws IOException { + patchVerificationSession(sessionId, gcmRegistrationId, null, null, null, null); } /** Note: Setting a KBS Pin will clear this */ @@ -558,7 +593,7 @@ public class PushServiceSocket { public SignalServiceMessagesResult getMessages(boolean allowStories) throws IOException { Map headers = Collections.singletonMap("X-Signal-Receive-Stories", allowStories ? "true" : "false"); - + try (Response response = makeServiceRequest(String.format(MESSAGE_PATH, ""), "GET", (RequestBody) null, headers, NO_HANDLER, Optional.empty(), false)) { validateServiceResponse(response); @@ -2214,6 +2249,8 @@ public class PushServiceSocket { } } + public enum VerificationCodeTransport { SMS, VOICE } + private static class RegistrationLock { @JsonProperty private String pin; @@ -2485,28 +2522,87 @@ public class PushServiceSocket { makeServiceRequest(String.format(REPORT_SPAM, serviceId.toString(), serverGuid), "POST", JsonUtil.toJson(new SpamTokenMessage(reportingToken))); } - private static class VerificationCodeResponseHandler implements ResponseCodeHandler { + private static class RegistrationSessionResponseHandler implements ResponseCodeHandler { + @Override - public void handle(int responseCode, ResponseBody responseBody) throws NonSuccessfulResponseCodeException, PushNetworkException { + public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException { switch (responseCode) { - case 400: + case 404: + throw new NoSuchSessionException(); + case 409: + RegistrationSessionMetadataJson response; try { - String body = responseBody != null ? readBodyString(responseBody) : ""; - if (body.isEmpty()) { - throw new ImpossiblePhoneNumberException(); - } else { - throw NonNormalizedPhoneNumberException.forResponse(body); - } - } catch (MalformedResponseException e) { - Log.w(TAG, "Unable to parse 400 response! Assuming a generic 400."); - throw new ImpossiblePhoneNumberException(); + response = JsonUtil.fromJson(body.string(), RegistrationSessionMetadataJson.class); + } catch (IOException e) { + Log.e(TAG, "Unable to read response body.", e); + throw new NonSuccessfulResponseCodeException(409); + } + if (response.pushChallengedRequired()) { + throw new PushChallengeRequiredException(); + } else if (response.captchaRequired()) { + throw new CaptchaRequiredException(); + } else { + throw new HttpConflictException(); } - case 402: - throw new CaptchaRequiredException(); } } } + private static class RegistrationCodeRequestResponseHandler implements ResponseCodeHandler { + @Override public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException { + switch (responseCode) { + case 400: + throw new InvalidTransportModeException(); + case 404: + throw new NoSuchSessionException(); + case 409: + RegistrationSessionMetadataJson sessionMetadata; + try { + sessionMetadata = JsonUtil.fromJson(body.string(), RegistrationSessionMetadataJson.class); + } catch (IOException e) { + Log.e(TAG, "Unable to read response body.", e); + throw new NonSuccessfulResponseCodeException(409); + } + if (sessionMetadata.getVerified()) { + throw new AlreadyVerifiedException(); + } else if (sessionMetadata.getNextVerificationAttempt() == null) { + // Note: this explicitly requires Verified to be false + throw new MustRequestNewCodeException(); + } else { + throw new HttpConflictException(); + } + case 502: + VerificationCodeFailureResponseBody codeFailureResponse; + try { + codeFailureResponse = JsonUtil.fromJson(body.string(), VerificationCodeFailureResponseBody.class); + } catch (IOException e) { + Log.e(TAG, "Unable to read response body.", e); + throw new NonSuccessfulResponseCodeException(502); + } + + throw new ExternalServiceFailureException(codeFailureResponse.getPermanentFailure(), codeFailureResponse.getReason()); + } + } + } + + + private static RegistrationSessionMetadataResponse parseSessionMetadataResponse(Response response) throws IOException { + long serverDeliveredTimestamp = 0; + try { + String stringValue = response.header(SERVER_DELIVERED_TIMESTAMP_HEADER); + stringValue = stringValue != null ? stringValue : "0"; + + serverDeliveredTimestamp = Long.parseLong(stringValue); + } catch (NumberFormatException e) { + Log.w(TAG, e); + } + + RegistrationSessionMetadataHeaders responseHeaders = new RegistrationSessionMetadataHeaders(serverDeliveredTimestamp); + RegistrationSessionMetadataJson responseBody = JsonUtil.fromJson(readBodyString(response), RegistrationSessionMetadataJson.class); + + return new RegistrationSessionMetadataResponse(responseHeaders, responseBody); + } + public static final class GroupHistory { private final GroupChanges groupChanges; private final Optional contentRange; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RegistrationSessionMetadataResponse.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RegistrationSessionMetadataResponse.kt new file mode 100644 index 0000000000..9b2be2f24a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RegistrationSessionMetadataResponse.kt @@ -0,0 +1,34 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * This is a parsed, POJO representation of the server response describing the state of the registration session. + * The useful headers and the request body are wrapped in a single holder class. + */ +data class RegistrationSessionMetadataResponse( + val headers: RegistrationSessionMetadataHeaders, + val body: RegistrationSessionMetadataJson +) + +data class RegistrationSessionMetadataHeaders( + val timestamp: Long +) + +data class RegistrationSessionMetadataJson( + @JsonProperty("id") val id: String, + @JsonProperty("nextSms") val nextSms: Int?, + @JsonProperty("nextCall") val nextCall: Int?, + @JsonProperty("nextVerificationAttempt") val nextVerificationAttempt: Int?, + @JsonProperty("allowedToRequestCode") val allowedToRequestCode: Boolean, + @JsonProperty("requestedInformation") val requestedInformation: List, + @JsonProperty("verified") val verified: Boolean, +) { + fun pushChallengedRequired(): Boolean { + return requestedInformation.contains("pushChallenge") + } + + fun captchaRequired(): Boolean { + return requestedInformation.contains("captcha") + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RegistrationSessionRequestBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RegistrationSessionRequestBody.kt new file mode 100644 index 0000000000..714da0ab47 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/RegistrationSessionRequestBody.kt @@ -0,0 +1,13 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import org.whispersystems.signalservice.api.account.AccountAttributes + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class RegistrationSessionRequestBody( + @JsonProperty val sessionId: String? = null, + @JsonProperty val recoveryPassword: String? = null, + @JsonProperty val accountAttributes: AccountAttributes, + @JsonProperty val skipDeviceTransfer: Boolean +) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/UpdateVerificationSessionRequestBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/UpdateVerificationSessionRequestBody.kt new file mode 100644 index 0000000000..18b9fb3fa5 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/UpdateVerificationSessionRequestBody.kt @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class UpdateVerificationSessionRequestBody( + @JsonProperty val captcha: String?, + @JsonProperty val pushToken: String?, + @JsonProperty val pushChallenge: String?, + @JsonProperty val mcc: String?, + @JsonProperty val mnc: String?, +) { + @JsonProperty + val pushTokenType: String? = if (pushToken != null) "fcm" else null +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerificationCodeFailureResponseBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerificationCodeFailureResponseBody.kt new file mode 100644 index 0000000000..d5ddb5b8b4 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerificationCodeFailureResponseBody.kt @@ -0,0 +1,12 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Jackson parser for the response body from the server explaining a failure. + * See also [org.whispersystems.signalservice.api.push.exceptions.ExternalServiceFailureException] + */ +data class VerificationCodeFailureResponseBody( + @JsonProperty val permanentFailure: Boolean, + @JsonProperty val reason: String +) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerificationSessionMetadataRequestBody.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerificationSessionMetadataRequestBody.kt new file mode 100644 index 0000000000..f09aa70f9b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerificationSessionMetadataRequestBody.kt @@ -0,0 +1,15 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class VerificationSessionMetadataRequestBody( + @JsonProperty val number: String, + @JsonProperty val pushToken: String?, + @JsonProperty val mcc: String?, + @JsonProperty val mnc: String?, +) { + @JsonProperty + val pushTokenType: String? = if (pushToken != null) "fcm" else null +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java index bb74ec964e..4fde11c6c0 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/VerifyAccountResponse.java @@ -13,6 +13,9 @@ public class VerifyAccountResponse { @JsonProperty public boolean storageCapable; + @JsonProperty + public String number; + @JsonCreator public VerifyAccountResponse() {}