From b8185e715d7fe2cf63eb0f3411cc03b85c43f027 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 24 Sep 2026 15:50:11 -0400 Subject: [PATCH] Validate login receipt expiration + level. --- .../signal/registration/NetworkController.kt | 4 +- .../registration/RegistrationRepository.kt | 38 +++++++++++++++ .../registration/SignalLoginPurchaseTest.kt | 47 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 8585ed7843..2708dd2b26 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -148,8 +148,8 @@ interface NetworkController { * * Retries for the same [purchaseIdentifier] must reuse the same [receiptCredentialRequest]. * - * Implementations must apply the same validations to the returned credential as are applied to one-time donation - * receipts, and the expected receipt expiration is `purchaseDate + 5 * 366` days, plus padding for clock skew. + * The expected receipt expiration is `purchaseDate + 5 * 366` days. [RegistrationRepository] validates the level + * and expiration of the credential this issues. * * `POST /v1/login-purchase/receipt_credentials` */ diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index c207f3587f..c1cbc3c9db 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -108,6 +108,8 @@ import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class RegistrationRepository( @@ -137,6 +139,10 @@ class RegistrationRepository( private val TAG = Log.tag(RegistrationRepository::class) private val json = Json { ignoreUnknownKeys = true } + private val SIGNAL_LOGIN_RECEIPT_LIFESPAN = (5 * 366).days + private val SIGNAL_LOGIN_RECEIPT_CLOCK_SKEW_BUFFER = 2.days + private val SIGNAL_LOGIN_RECEIPT_MAX_LIFESPAN = SIGNAL_LOGIN_RECEIPT_LIFESPAN + SIGNAL_LOGIN_RECEIPT_CLOCK_SKEW_BUFFER + /** Builds a repository from the module's injected [RegistrationDependencies]. */ fun create(context: Context): RegistrationRepository { val dependencies = RegistrationDependencies.get() @@ -685,6 +691,17 @@ class RegistrationRepository( } } + val configuration = fetchSignalLoginConfiguration() + if (configuration == null) { + Log.w(TAG, "[redeemSignalLoginPurchaseAndRegister] No Signal Login configuration, so we cannot validate the issued credential.") + return SignalLoginPurchaseResult.NetworkError + } + + if (!isSignalLoginReceiptCredentialValid(credential, configuration.level)) { + Log.w(TAG, "[redeemSignalLoginPurchaseAndRegister] The service issued a credential that failed validation.") + return SignalLoginPurchaseResult.UnknownError + } + val presentation = when (val built = networkController.createReceiptCredentialPresentation(credential)) { is ReceiptCredentialResult.Success -> built.value ReceiptCredentialResult.VerificationFailed -> { @@ -714,6 +731,27 @@ class RegistrationRepository( } } + /** + * Guards against the service tagging a credential with a distinctive level or expiration that would let it link the + * purchase to the account that redeems it. + */ + private fun isSignalLoginReceiptCredentialValid(credential: ReceiptCredential, expectedLevel: Long): Boolean { + val now = System.currentTimeMillis().milliseconds + val maxExpirationTime = now + SIGNAL_LOGIN_RECEIPT_MAX_LIFESPAN + val isCorrectLevel = credential.receiptLevel == expectedLevel + val isExpiration86400 = credential.receiptExpirationTime % 86400 == 0L + val isExpirationInTheFuture = credential.receiptExpirationTime.seconds > now + val isExpirationWithinMax = credential.receiptExpirationTime.seconds <= maxExpirationTime + + Log.i( + TAG, + "[isSignalLoginReceiptCredentialValid] isCorrectLevel: $isCorrectLevel (actual: ${credential.receiptLevel}, expected: $expectedLevel), " + + "isExpiration86400: $isExpiration86400, isExpirationInTheFuture: $isExpirationInTheFuture, isExpirationWithinMax: $isExpirationWithinMax" + ) + + return isCorrectLevel && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax + } + /** * The service rejects a retry that redeems the same purchase with a different request, so we reuse the context we * persisted for this purchase if there is one. diff --git a/feature/registration/src/test/java/org/signal/registration/SignalLoginPurchaseTest.kt b/feature/registration/src/test/java/org/signal/registration/SignalLoginPurchaseTest.kt index 80bbeae0d4..9c42c6c2a2 100644 --- a/feature/registration/src/test/java/org/signal/registration/SignalLoginPurchaseTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/SignalLoginPurchaseTest.kt @@ -38,6 +38,7 @@ import org.signal.registration.fakes.FakeNetworkController import org.signal.registration.fakes.FakeOneTimePurchaseApi import org.signal.registration.fakes.FakeStorageController import org.signal.registration.fakes.SystemOutLogger +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds /** @@ -249,6 +250,52 @@ class SignalLoginPurchaseTest { assertThat(purchaseApi.consumedTokens).isEmpty() } + // ==================== credential validation ==================== + + @Test + fun `a credential with the wrong level is rejected`() = runTest { + networkController.onCreateLoginPurchaseReceiptCredential = { request -> + RequestResult.Success(networkController.issueLoginReceiptCredential(request, level = FakeNetworkController.LOGIN_RECEIPT_LEVEL + 1)) + } + + assertCredentialRejected() + } + + @Test + fun `a credential whose expiration is not day aligned is rejected`() = runTest { + networkController.onCreateLoginPurchaseReceiptCredential = { request -> + RequestResult.Success(networkController.issueLoginReceiptCredential(request, expirationSeconds = networkController.defaultReceiptExpirationSeconds() + 1)) + } + + assertCredentialRejected() + } + + @Test + fun `a credential that expires too far in the future is rejected`() = runTest { + networkController.onCreateLoginPurchaseReceiptCredential = { request -> + RequestResult.Success(networkController.issueLoginReceiptCredential(request, expirationSeconds = networkController.defaultReceiptExpirationSeconds() + 7.days.inWholeSeconds)) + } + + assertCredentialRejected() + } + + @Test + fun `a credential that has already expired is rejected`() = runTest { + networkController.onCreateLoginPurchaseReceiptCredential = { request -> + RequestResult.Success(networkController.issueLoginReceiptCredential(request, expirationSeconds = networkController.defaultReceiptExpirationSeconds() - FakeNetworkController.LOGIN_RECEIPT_LIFESPAN.inWholeSeconds)) + } + + assertCredentialRejected() + } + + private suspend fun assertCredentialRejected() { + val result = purchaseAndRegister() + + assertThat(result).isInstanceOf(SignalLoginPurchaseResult.UnknownError::class) + assertThat(networkController.lastRegisterAccountRequest).isNull() + assertThat(purchaseApi.consumedTokens).isEmpty() + } + // ==================== pending payment ==================== @Test