Initial error handling for registration v2.

This commit is contained in:
Nicholas Tinsley 2024-05-06 15:07:59 -04:00 committed by Alex Hart
parent 49ba83dda8
commit 9c5bb4aa17
7 changed files with 328 additions and 54 deletions

View file

@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.registration.v2.data
import android.app.backup.BackupManager
import android.content.Context
import androidx.annotation.WorkerThread
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.PushChallengeRequest
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.v2.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
@ -59,6 +61,7 @@ import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.concurrent.CountDownLatch
@ -142,7 +145,6 @@ object RegistrationRepository {
/**
* Takes a server response from a successful registration and persists the relevant data.
*/
@WorkerThread
@JvmStatic
suspend fun registerAccountLocally(context: Context, registrationData: RegistrationData, response: AccountRegistrationResult, reglockEnabled: Boolean) =
withContext(Dispatchers.IO) {
@ -258,20 +260,21 @@ object RegistrationRepository {
* 2. (Optional) If the session has any proof requirements ("challenges"), the user must solve them and submit the proof.
* 3. Once the service responds we are allowed to, we request the verification code.
*/
suspend fun requestSmsCode(context: Context, e164: String, password: String, mcc: String?, mnc: String?, mode: Mode = Mode.SMS_WITHOUT_LISTENER): NetworkResult<RegistrationSessionMetadataResponse> =
suspend fun requestSmsCode(context: Context, e164: String, password: String, mcc: String?, mnc: String?, mode: Mode = Mode.SMS_WITHOUT_LISTENER): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val activeSession = if (fcmToken == null) {
// TODO [regv2]
val notImplementedError = NotImplementedError()
Log.w(TAG, "Not yet implemented!", notImplementedError)
NetworkResult.ApplicationError(notImplementedError)
} else {
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
activeSession.then { session ->
val result = (
if (fcmToken == null) {
// TODO [regv2]
val notImplementedError = NotImplementedError()
Log.w(TAG, "Not yet implemented!", notImplementedError)
NetworkResult.ApplicationError(notImplementedError)
} else {
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
).then { session ->
val sessionId = session.body.id
SignalStore.registrationValues().sessionId = sessionId
SignalStore.registrationValues().sessionE164 = e164
@ -290,6 +293,8 @@ object RegistrationRepository {
api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
}
}
return@withContext VerificationCodeRequestResult.from(result)
}
/**
@ -397,39 +402,45 @@ object RegistrationRepository {
return timestamp + deltaSeconds.seconds.inWholeMilliseconds
}
suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): AuthCredentials? =
suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): BackupAuthCheckResult =
withContext(Dispatchers.IO) {
val usernamePasswords = SignalStore.svr()
.authTokenList
.take(10)
.map {
it.replace("Basic ", "").trim()
}
.map {
Base64.decode(it) // TODO [regv2]: figure out why Android Studio doesn't like mapCatching
}
.map {
String(it, StandardCharsets.ISO_8859_1)
}
if (usernamePasswords.isEmpty()) {
return@withContext null
}
val usernamePasswords = async { retrieveLocalSvrCredentials() }
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val authCheck = api.getSvrAuthCredential(e164, usernamePasswords)
if (authCheck !is NetworkResult.Success) {
return@withContext null
}
val result = api.getSvrAuthCredential(e164, usernamePasswords.await())
.runIfSuccessful {
val removedInvalidTokens = SignalStore.svr().removeAuthTokens(it.invalid)
if (removedInvalidTokens) {
BackupManager(context).dataChanged()
}
}
val removedInvalidTokens = SignalStore.svr().removeAuthTokens(authCheck.result.invalid)
if (removedInvalidTokens) {
BackupManager(context).dataChanged()
}
return@withContext authCheck.result.match
return@withContext BackupAuthCheckResult.from(result)
}
private suspend fun retrieveLocalSvrCredentials(): List<String> = withContext(Dispatchers.IO) {
return@withContext SignalStore.svr()
.authTokenList
.asSequence()
.filterNotNull()
.take<String>(10)
.map<String, String> {
it.replace("Basic ", "").trim()
}
.mapNotNull<String, ByteArray> {
try {
Base64.decode(it)
} catch (e: IOException) {
Log.w(TAG, "Encountered error trying to decode a token!", e)
null
}
}
.map<ByteArray, String> {
String(it, StandardCharsets.ISO_8859_1)
}
.toList()
}
enum class Mode(val isSmsRetrieverSupported: Boolean) {
SMS_WITH_LISTENER(true), SMS_WITHOUT_LISTENER(false), PHONE_CALL(false)
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.BackupAuthCheckResponse
/**
* This is a processor to map a [BackupAuthCheckResponse] to all the known outcomes.
*/
sealed class BackupAuthCheckResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
@JvmStatic
fun from(networkResult: NetworkResult<BackupAuthCheckResponse>): BackupAuthCheckResult {
return when (networkResult) {
is NetworkResult.Success -> {
val match = networkResult.result.match
if (match != null) {
SuccessWithCredentials(match)
} else {
SuccessWithoutCredentials()
}
}
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
is NetworkResult.NetworkError -> UnknownError(networkResult.exception)
is NetworkResult.StatusCodeError -> UnknownError(networkResult.exception)
}
}
}
class SuccessWithCredentials(val authCredentials: AuthCredentials) : BackupAuthCheckResult(null)
class SuccessWithoutCredentials : BackupAuthCheckResult(null)
class UnknownError(cause: Throwable) : BackupAuthCheckResult(cause)
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
/**
* This is a merging of the NetworkResult pattern and the Processor pattern of registration v1.
* The goal is to enumerate all possible responses as sealed classes, which means the consumer will be able to handle them in an exhaustive when clause
*
* @property errorCause the [Throwable] that caused the Error. Null if the network request was successful.
*
*/
abstract class RegistrationResult(private val errorCause: Throwable?) {
fun isSuccess(): Boolean {
return errorCause == null
}
fun getCause(): Throwable {
if (errorCause == null) {
throw IllegalStateException("Cannot get cause from successful processor!")
}
return errorCause
}
}

View file

@ -0,0 +1,117 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2.data.network
import okio.IOException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException
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.MalformedRequestException
import org.whispersystems.signalservice.api.push.exceptions.NonNormalizedPhoneNumberException
import org.whispersystems.signalservice.api.push.exceptions.PushChallengeRequiredException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException
import org.whispersystems.signalservice.api.push.exceptions.RegistrationRetryException
import org.whispersystems.signalservice.api.push.exceptions.TokenNotAcceptedException
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import org.whispersystems.signalservice.internal.util.JsonUtil
/**
* This is a processor to map a [RegistrationSessionMetadataResponse] to all the known outcomes.
*/
sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
private val TAG = Log.tag(VerificationCodeRequestResult::class.java)
@JvmStatic
fun from(networkResult: NetworkResult<RegistrationSessionMetadataResponse>): VerificationCodeRequestResult {
return when (networkResult) {
is NetworkResult.Success -> {
val challenges = networkResult.result.body.requestedInformation
if (challenges.isNotEmpty()) {
ChallengeRequired(challenges)
} else {
Success(
sessionId = networkResult.result.body.id,
nextSms = RegistrationRepository.deriveTimestamp(networkResult.result.headers, networkResult.result.body.nextSms),
nextCall = RegistrationRepository.deriveTimestamp(networkResult.result.headers, networkResult.result.body.nextCall)
)
}
}
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
is NetworkResult.NetworkError -> UnknownError(networkResult.exception)
is NetworkResult.StatusCodeError -> {
when (val cause = networkResult.exception) {
is PushChallengeRequiredException -> createChallengeRequiredProcessor(networkResult)
is CaptchaRequiredException -> createChallengeRequiredProcessor(networkResult)
is RateLimitException -> createRateLimitProcessor(cause)
is ImpossiblePhoneNumberException -> ImpossibleNumber(cause)
is NonNormalizedPhoneNumberException -> NonNormalizedNumber(cause)
is TokenNotAcceptedException -> TokenNotAccepted(cause)
is ExternalServiceFailureException -> ExternalServiceFailure(cause)
is InvalidTransportModeException -> InvalidTransportModeFailure(cause)
is MalformedRequestException -> MalformedRequest(cause)
is RegistrationRetryException -> MustRetry(cause)
else -> UnknownError(cause)
}
}
}
}
private fun createChallengeRequiredProcessor(errorResult: NetworkResult.StatusCodeError<RegistrationSessionMetadataResponse>): VerificationCodeRequestResult {
if (errorResult.body == null) {
Log.w(TAG, "Attempted to parse error body with response code ${errorResult.code} for list of requested information, but body was null.")
return UnknownError(errorResult.exception)
}
try {
val response = JsonUtil.fromJson(errorResult.body, RegistrationSessionMetadataJson::class.java)
return ChallengeRequired(response.requestedInformation)
} catch (parseException: IOException) {
Log.w(TAG, "Attempted to parse error body for list of requested information, but encountered exception.", parseException)
return UnknownError(parseException)
}
}
private fun createRateLimitProcessor(exception: RateLimitException): VerificationCodeRequestResult {
return if (exception.retryAfterMilliseconds.isPresent) {
RateLimited(exception, exception.retryAfterMilliseconds.get())
} else {
AttemptsExhausted(exception)
}
}
}
class Success(val sessionId: String, val nextSms: Long, val nextCall: Long) : VerificationCodeRequestResult(null)
class ChallengeRequired(val challenges: List<String>) : VerificationCodeRequestResult(null)
class RateLimited(cause: Throwable, val timeRemaining: Long) : VerificationCodeRequestResult(cause)
class AttemptsExhausted(cause: Throwable) : VerificationCodeRequestResult(cause)
class ImpossibleNumber(cause: Throwable) : VerificationCodeRequestResult(cause)
class NonNormalizedNumber(cause: Throwable) : VerificationCodeRequestResult(cause)
class TokenNotAccepted(cause: Throwable) : VerificationCodeRequestResult(cause)
class ExternalServiceFailure(cause: Throwable) : VerificationCodeRequestResult(cause)
class InvalidTransportModeFailure(cause: Throwable) : VerificationCodeRequestResult(cause)
class MalformedRequest(cause: Throwable) : VerificationCodeRequestResult(cause)
class MustRetry(cause: Throwable) : VerificationCodeRequestResult(cause)
class UnknownError(cause: Throwable) : VerificationCodeRequestResult(cause)
}

View file

@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -27,6 +28,19 @@ import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.v2.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.v2.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.AttemptsExhausted
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ChallengeRequired
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ExternalServiceFailure
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.ImpossibleNumber
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MalformedRequest
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.NonNormalizedNumber
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.RateLimited
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.MustRetry
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.Success
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.TokenNotAccepted
import org.thoughtcrime.securesms.registration.v2.data.network.VerificationCodeRequestResult.UnknownError
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
@ -42,9 +56,15 @@ import java.io.IOException
class RegistrationV2ViewModel : ViewModel() {
private val store = MutableStateFlow(RegistrationV2State())
private val password = Util.getSecret(18) // TODO [regv2]: persist this
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
Log.w(TAG, "CoroutineExceptionHandler invoked.", exception)
store.update {
it.copy(networkError = exception)
}
}
val uiState = store.asLiveData()
init {
@ -80,7 +100,7 @@ class RegistrationV2ViewModel : ViewModel() {
}
fun fetchFcmToken(context: Context) {
viewModelScope.launch {
viewModelScope.launch(context = coroutineExceptionHandler) {
val fcmToken = RegistrationRepository.getFcmToken(context)
store.update {
it.copy(registrationCheckpoint = RegistrationCheckpoint.PUSH_NETWORK_AUDITED, isFcmSupported = true, fcmToken = fcmToken)
@ -120,25 +140,70 @@ class RegistrationV2ViewModel : ViewModel() {
store.update {
it.copy(canSkipSms = true)
}
} else {
viewModelScope.launch {
val svrCredentials = RegistrationRepository.hasValidSvrAuthCredentials(context, e164, password)
return
}
if (svrCredentials != null) {
// Re-registration when credentials stored in backup.
viewModelScope.launch {
val svrCredentialsResult = RegistrationRepository.hasValidSvrAuthCredentials(context, e164, password)
when (svrCredentialsResult) {
is BackupAuthCheckResult.UnknownError -> {
handleGenericError(svrCredentialsResult.getCause())
return@launch
}
is BackupAuthCheckResult.SuccessWithCredentials -> {
Log.d(TAG, "Found local valid SVR auth credentials.")
store.update {
it.copy(canSkipSms = true, svrAuthCredentials = svrCredentials)
it.copy(canSkipSms = true, svrAuthCredentials = svrCredentialsResult.authCredentials)
}
} else {
val codeRequestResponse = RegistrationRepository.requestSmsCode(context, e164, password, mccMncProducer.mcc, mccMncProducer.mnc).successOrThrow()
return@launch
}
is BackupAuthCheckResult.SuccessWithoutCredentials -> Log.d(TAG, "No local SVR auth credentials could be found and/or validated.")
}
val codeRequestResponse = RegistrationRepository.requestSmsCode(context, e164, password, mccMncProducer.mcc, mccMncProducer.mnc)
when (codeRequestResponse) {
is UnknownError -> {
handleGenericError(codeRequestResponse.getCause())
return@launch
}
is Success -> {
updateFcmToken(context)
store.update {
it.copy(sessionId = codeRequestResponse.body.id, nextSms = RegistrationRepository.deriveTimestamp(codeRequestResponse.headers, codeRequestResponse.body.nextSms), nextCall = RegistrationRepository.deriveTimestamp(codeRequestResponse.headers, codeRequestResponse.body.nextCall), registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED)
it.copy(
sessionId = codeRequestResponse.sessionId,
nextSms = codeRequestResponse.nextSms,
nextCall = codeRequestResponse.nextCall,
registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
)
}
}
is AttemptsExhausted -> Log.w(TAG, "TODO")
is ChallengeRequired -> Log.w(TAG, "TODO")
is ImpossibleNumber -> Log.w(TAG, "TODO")
is NonNormalizedNumber -> Log.w(TAG, "TODO")
is RateLimited -> Log.w(TAG, "TODO")
is ExternalServiceFailure -> Log.w(TAG, "TODO")
is InvalidTransportModeFailure -> Log.w(TAG, "TODO")
is MalformedRequest -> Log.w(TAG, "TODO")
is MustRetry -> Log.w(TAG, "TODO")
is TokenNotAccepted -> Log.w(TAG, "TODO")
}
}
}
private fun handleGenericError(cause: Throwable) {
Log.w(TAG, "Encountered unknown error!", cause)
store.update {
it.copy(inProgress = false, networkError = cause)
}
}
private fun setRecoveryPassword(recoveryPassword: String?) {
store.update {
it.copy(recoveryPassword = recoveryPassword)
@ -164,7 +229,7 @@ class RegistrationV2ViewModel : ViewModel() {
if (RegistrationRepository.canUseLocalRecoveryPassword()) {
if (RegistrationRepository.doesPinMatchLocalHash(pin)) {
Log.d(TAG, "Found recovery password, attempting to re-register.")
viewModelScope.launch {
viewModelScope.launch(context = coroutineExceptionHandler) {
verifyReRegisterInternal(context, pin, SignalStore.svr().getOrCreateMasterKey())
setInProgress(false)
}
@ -180,7 +245,7 @@ class RegistrationV2ViewModel : ViewModel() {
val authCredentials = store.value.svrAuthCredentials
if (authCredentials != null) {
Log.d(TAG, "Found SVR auth credentials, fetching recovery password from SVR.")
viewModelScope.launch {
viewModelScope.launch(context = coroutineExceptionHandler) {
try {
val masterKey = RegistrationRepository.fetchMasterKeyFromSvrRemote(pin, authCredentials)
setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
@ -254,7 +319,7 @@ class RegistrationV2ViewModel : ViewModel() {
}
val e164: String = getCurrentE164() ?: throw IllegalStateException()
viewModelScope.launch {
viewModelScope.launch(context = coroutineExceptionHandler) {
val registrationData = getRegistrationData(code)
val verificationResponse = RegistrationRepository.submitVerificationCode(context, e164, password, sessionId, registrationData).successOrThrow()

View file

@ -96,6 +96,10 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
presentRegisterButton(sharedState)
presentProgressBar(sharedState.inProgress, sharedState.isReRegister)
sharedState.networkError?.let {
presentNetworkError(it)
}
if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
moveToEnterPinScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
@ -226,6 +230,15 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
}
private fun presentNetworkError(networkError: Throwable) {
// TODO [regv2]: check specific errors with a when clause
Log.i(TAG, "Unknown error during verification code request", networkError)
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun onRegistrationButtonClicked() {
ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout)
sharedViewModel.setInProgress(true)

View file

@ -51,7 +51,7 @@ sealed class NetworkResult<T>(
data class NetworkError<T>(val exception: IOException) : NetworkResult<T>()
/** Indicates we got a response, but it was a non-2xx response. */
data class StatusCodeError<T>(val code: Int, val body: String?, val exception: IOException) : NetworkResult<T>()
data class StatusCodeError<T>(val code: Int, val body: String?, val exception: NonSuccessfulResponseCodeException) : NetworkResult<T>()
/** Indicates that the application somehow failed in a way unrelated to network activity. Usually a runtime crash. */
data class ApplicationError<T>(val throwable: Throwable) : NetworkResult<T>()