Validate login receipt expiration + level.

This commit is contained in:
Greyson Parrelli
2026-09-25 12:50:39 -04:00
committed by Michelle Tang
parent 1e9b9db92e
commit b8185e715d
3 changed files with 87 additions and 2 deletions
@@ -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`
*/
@@ -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.
@@ -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