mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Validate login receipt expiration + level.
This commit is contained in:
committed by
Michelle Tang
parent
1e9b9db92e
commit
b8185e715d
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user