From 8407b033c8e687ffdf9d88a782bde7f7c3553f4f Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 25 Aug 2026 15:13:32 -0400 Subject: [PATCH] Update RegistrationApi with new server fields. --- .../EnterAepForLocalBackupViewModel.kt | 10 +++++ ...ForRemoteBackupPreRegistrationViewModel.kt | 10 +++++ .../phonenumber/PhoneNumberEntryViewModel.kt | 10 +++++ .../PinEntryForRegistrationLockViewModel.kt | 5 +++ .../pinentry/PinEntryForSmsBypassViewModel.kt | 5 +++ .../quickrestore/QuickRestoreQrViewModel.kt | 9 +++++ .../VerificationCodeViewModel.kt | 5 +++ .../signal/network/api/RegistrationApiV2.kt | 37 +++++++++++++++---- 8 files changed, 84 insertions(+), 7 deletions(-) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt index 4e4d14735f..fa47f7925f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForLocalBackupViewModel.kt @@ -175,6 +175,16 @@ class EnterAepForLocalBackupViewModel( is RegisterAccountError.DeviceTransferPossible -> { error("[Submit] Device transfer possible. This should not happen with RRP-based registration.") } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[Submit] Unexpected registration error: $error") + stateEmitter( + inputState.copy( + isRegistering = false, + registrationError = RegistrationError.UnknownError + ) + ) + } } } is RequestResult.RetryableNetworkError -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt index 887bc9efb7..6a475c4ba3 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepForRemoteBackupPreRegistrationViewModel.kt @@ -143,6 +143,16 @@ class EnterAepForRemoteBackupPreRegistrationViewModel( is RegisterAccountError.DeviceTransferPossible -> { error("[Submit] Device transfer possible. This should not happen with RRP-based registration.") } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[Submit] Unexpected registration error: $error") + stateEmitter( + inputState.copy( + isRegistering = false, + registrationError = RegistrationError.UnknownError + ) + ) + } } } is RequestResult.RetryableNetworkError -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index d89b346726..29951c784c 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -354,6 +354,11 @@ class PhoneNumberEntryViewModel( parentEventEmitter(RegistrationFlowEvent.RecoveryPasswordInvalid) state = state.copy(preExistingRegistrationData = null) } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[Register] Unexpected registration error: $error") + return state.copy(dialogs = state.dialogs.copy(unknownError = true)) + } } } is RequestResult.RetryableNetworkError -> { @@ -459,6 +464,11 @@ class PhoneNumberEntryViewModel( Log.w(TAG, "[LocalRestore] Device transfer possible. Falling back to session-based registration.") applySessionBasedRegistration(state, e164, parentEventEmitter) } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[LocalRestore] Unexpected registration error: $error") + state.copy(dialogs = state.dialogs.copy(unknownError = true)) + } } } is RequestResult.RetryableNetworkError -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index 350ecdc2ef..3f6b858474 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -218,6 +218,11 @@ class PinEntryForRegistrationLockViewModel( parentEventEmitter.navigateBack() state } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[PinEntered] Unexpected error when registering: $error") + state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true)) + } } } is RequestResult.RetryableNetworkError -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt index 551ae667e2..cec930767f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt @@ -217,6 +217,11 @@ class PinEntryForSmsBypassViewModel( parentEventEmitter(RegistrationFlowEvent.ResetState) state } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[Register] Unexpected error when registering: $error") + state.copy(loading = false, dialogs = state.dialogs.copy(unknownError = true)) + } } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt index 640c5394fa..6646a86ec5 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt @@ -183,6 +183,15 @@ class QuickRestoreQrViewModel( errorMessage = null ) } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[Register] Unexpected registration error: $error") + _state.value = _state.value.copy( + isRegistering = false, + showRegistrationError = true, + errorMessage = null + ) + } } } is RequestResult.RetryableNetworkError -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt index 45e5177805..cd104c91ef 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/verificationcode/VerificationCodeViewModel.kt @@ -417,6 +417,11 @@ class VerificationCodeViewModel( is RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> { error("[Register] Got told the registration recovery password incorrect. We don't use the RRP in this flow, and should never get this error. Resetting. Message: ${error.message}") } + RegisterAccountError.TotpMissingOrIncorrect, + RegisterAccountError.PostQuantumRatchetRequired -> { + Log.w(TAG, "[Register] Unexpected error when registering account: $error") + state.copy(snackbars = state.snackbars.copy(registrationError = true)) + } } } is RequestResult.RetryableNetworkError -> { diff --git a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt index fd35a49712..417e3c14d7 100644 --- a/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt +++ b/lib/network/src/main/java/org/signal/network/api/RegistrationApiV2.kt @@ -39,7 +39,6 @@ import org.signal.network.rest.bodyString import org.signal.network.rest.toTypedResult import java.net.URLEncoder import java.util.Locale -import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -60,6 +59,9 @@ class RegistrationApiV2( companion object { private val APPLICATION_JSON = "application/json".toMediaType() + /** Basic auth username for a fresh registration of an account that has no phone number. Must not parse as an e164 or a UUID. */ + private const val NO_NUMBER_AUTH_USERNAME = "no_number" + /** Drops null properties instead of emitting explicit nulls, for bodies where a field is meant to be absent entirely. */ @OptIn(ExperimentalSerializationApi::class) private val JSON_OMITTING_NULLS = Json(SignalJson.json) { explicitNulls = false } @@ -280,15 +282,19 @@ class RegistrationApiV2( * * `POST /v1/registration` * - 200: Success, body is the account response + * - 400: Session id is invalid * - 401: Session not found or not verified * - 403: Registration recovery password is incorrect * - 409: Device transfer is possible * - 422: Request is invalid * - 423: Registration lock is active * - 429: Rate limited + * - 441: A TOTP is required, but none was provided or the provided one was incorrect + * - 499: The client must support the post-quantum ratchet * * @param e164 The phone number in E.164 format (used as username for basic auth) * @param password The password for basic auth + * @param totp A TOTP one-time password, required when recovering an account that has TOTP keys. */ suspend fun registerAccount( e164: String, @@ -299,7 +305,8 @@ class RegistrationApiV2( aciPreKeys: PreKeyCollection, pniPreKeys: PreKeyCollection, fcmToken: String?, - skipDeviceTransfer: Boolean + skipDeviceTransfer: Boolean, + totp: Int? = null ): RequestResult { require((sessionId != null) xor (recoveryPassword != null)) { "You must supply one and only one of either: Session ID, or Recovery Password." } require(attributes.pniRegistrationId != null) { "Must send PNI key material when registering with a phone number." } @@ -308,6 +315,7 @@ class RegistrationApiV2( val body = RegisterAccountRequestBody( sessionId = sessionId, recoveryPassword = recoveryPassword, + totp = totp, accountAttributes = attributes, aciIdentityKey = Base64.encodeWithoutPadding(aciPreKeys.identityKey.serialize()), pniIdentityKey = Base64.encodeWithoutPadding(pniPreKeys.identityKey.serialize()), @@ -333,12 +341,14 @@ class RegistrationApiV2( parseSuccess = { SignalJson.json.decodeFromString(it.bodyString()) }, mapError = { error -> when (error.statusCode) { + 400, 422 -> RegisterAccountError.InvalidRequest(error.bodyString()) 401 -> RegisterAccountError.SessionNotFoundOrNotVerified(error.bodyString()) 403 -> RegisterAccountError.RegistrationRecoveryPasswordIncorrect(error.bodyString()) 409 -> RegisterAccountError.DeviceTransferPossible - 422 -> RegisterAccountError.InvalidRequest(error.bodyString()) 423 -> RegisterAccountError.RegistrationLock(SignalJson.json.decodeFromString(error.bodyString())) 429 -> RegisterAccountError.RateLimited(error.retryAfter()) + 441 -> RegisterAccountError.TotpMissingOrIncorrect + 499 -> RegisterAccountError.PostQuantumRatchetRequired else -> null } } @@ -412,9 +422,10 @@ class RegistrationApiV2( * Registers an account that has no phone number, redeeming the [receiptCredentialPresentation] issued by * [createLoginPurchaseReceiptCredential]. * - * The basic auth username is ignored by the service for a fresh numberless registration but must not be empty, so a - * random one is generated here. (Re-registering an existing numberless account authenticates by ACI instead; the - * service has not defined that flow yet.) + * The basic auth username is ignored by the service for a fresh numberless registration, but it must be present and + * must not parse as either an e164 or a UUID -- the service reads a UUID username as a request to recover the account + * with that ACI. (Re-registering an existing numberless account authenticates by ACI instead, and requires a TOTP if + * the account has TOTP keys; we don't support that flow yet.) * * PNI key material must not be sent for an account with no phone number, so [attributes] must have a null * `pniRegistrationId` and a null `discoverableByPhoneNumber`. @@ -425,6 +436,7 @@ class RegistrationApiV2( * - 409: Device transfer is possible * - 422: Request is invalid * - 429: Rate limited + * - 499: The client must support the post-quantum ratchet * * @param password The password for basic auth */ @@ -459,7 +471,7 @@ class RegistrationApiV2( host = RequestSpec.Host.Service, path = "/v1/registration", body = body.toJsonRequestBodyOmittingNulls(), - auth = RequestSpec.Auth.Header("Authorization", basicAuth(UUID.randomUUID().toString(), password)) + auth = RequestSpec.Auth.Header("Authorization", basicAuth(NO_NUMBER_AUTH_USERNAME, password)) ) ) @@ -471,6 +483,7 @@ class RegistrationApiV2( 409 -> RegisterAccountWithoutPhoneNumberError.DeviceTransferPossible 422 -> RegisterAccountWithoutPhoneNumberError.InvalidRequest(error.bodyString()) 429 -> RegisterAccountWithoutPhoneNumberError.RateLimited(error.retryAfter()) + 499 -> RegisterAccountWithoutPhoneNumberError.PostQuantumRatchetRequired else -> null } } @@ -874,6 +887,7 @@ class RegistrationApiV2( val sessionId: String? = null, val recoveryPassword: String? = null, val receiptCredentialPresentation: String? = null, + val totp: Int? = null, val accountAttributes: AccountAttributes, val aciIdentityKey: String, val pniIdentityKey: String?, @@ -983,6 +997,12 @@ class RegistrationApiV2( data class InvalidRequest(val message: String) : RegisterAccountError() data class RegistrationLock(val data: RegistrationLockResponse) : RegisterAccountError() data class RateLimited(val retryAfter: Duration) : RegisterAccountError() + + /** The account being recovered has TOTP keys, and no TOTP or an incorrect TOTP was supplied. */ + data object TotpMissingOrIncorrect : RegisterAccountError() + + /** The service requires that the registering client support the post-quantum ratchet. */ + data object PostQuantumRatchetRequired : RegisterAccountError() } sealed class RegisterAccountWithoutPhoneNumberError : BadRequestError { @@ -990,6 +1010,9 @@ class RegistrationApiV2( data object DeviceTransferPossible : RegisterAccountWithoutPhoneNumberError() data class InvalidRequest(val message: String) : RegisterAccountWithoutPhoneNumberError() data class RateLimited(val retryAfter: Duration) : RegisterAccountWithoutPhoneNumberError() + + /** The service requires that the registering client support the post-quantum ratchet. */ + data object PostQuantumRatchetRequired : RegisterAccountWithoutPhoneNumberError() } sealed class CreateLoginReceiptCredentialError : BadRequestError {