From bcd773910fac2c7dd906d1b78e4ca25d704dc24f Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 29 Jun 2023 12:31:30 +0200 Subject: [PATCH 1/9] Trusted entitlements: Use new signature verification format --- .../revenuecat/purchases/common/HTTPClient.kt | 9 +-- .../verification/SignatureVerificationMode.kt | 20 ++++-- .../common/verification/SigningManager.kt | 67 ++++++++++++------- .../purchases/strings/NetworkStrings.kt | 3 + .../common/HTTPClientVerificationTest.kt | 22 +++--- .../SignatureVerificationModeTest.kt | 11 +++ .../common/verification/SigningManagerTest.kt | 46 +++++++------ 7 files changed, 107 insertions(+), 71 deletions(-) diff --git a/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt b/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt index 097dd7dd53..99bd57219d 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt @@ -175,10 +175,9 @@ class HTTPClient( } val verificationResult = if (shouldSignResponse && - nonce != null && RCHTTPStatusCodes.isSuccessful(responseCode) ) { - verifyResponse(path, responseCode, connection, payload, nonce) + verifyResponse(path, connection, payload, nonce) } else { VerificationResult.NOT_REQUESTED } @@ -278,15 +277,13 @@ class HTTPClient( private fun verifyResponse( urlPath: String, - responseCode: Int, connection: URLConnection, payload: String?, - nonce: String, + nonce: String?, ): VerificationResult { return signingManager.verifyResponse( urlPath = urlPath, - responseCode = responseCode, - signature = connection.getHeaderField(HTTPResult.SIGNATURE_HEADER_NAME), + signatureString = connection.getHeaderField(HTTPResult.SIGNATURE_HEADER_NAME), nonce = nonce, body = payload, requestTime = getRequestTimeHeader(connection), diff --git a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt index 6bde03b972..dd54903859 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt @@ -19,8 +19,18 @@ sealed class SignatureVerificationMode { } } object Disabled : SignatureVerificationMode() - data class Informational(val signatureVerifier: SignatureVerifier) : SignatureVerificationMode() - data class Enforced(val signatureVerifier: SignatureVerifier) : SignatureVerificationMode() + data class Informational( + val signatureVerifier: SignatureVerifier, + override val intermediateSignatureHelper: IntermediateSignatureHelper = IntermediateSignatureHelper( + signatureVerifier, + ), + ) : SignatureVerificationMode() + data class Enforced( + val signatureVerifier: SignatureVerifier, + override val intermediateSignatureHelper: IntermediateSignatureHelper = IntermediateSignatureHelper( + signatureVerifier, + ), + ) : SignatureVerificationMode() val shouldVerify: Boolean get() = when (this) { @@ -32,10 +42,10 @@ sealed class SignatureVerificationMode { true } - val verifier: SignatureVerifier? + open val intermediateSignatureHelper: IntermediateSignatureHelper? get() = when (this) { is Disabled -> null - is Informational -> signatureVerifier - is Enforced -> signatureVerifier + is Informational -> intermediateSignatureHelper + is Enforced -> intermediateSignatureHelper } } diff --git a/common/src/main/java/com/revenuecat/purchases/common/verification/SigningManager.kt b/common/src/main/java/com/revenuecat/purchases/common/verification/SigningManager.kt index 5a243909a2..bb9e7d72cf 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/verification/SigningManager.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/verification/SigningManager.kt @@ -5,9 +5,9 @@ import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.errorLog import com.revenuecat.purchases.common.networking.Endpoint -import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes import com.revenuecat.purchases.common.warnLog import com.revenuecat.purchases.strings.NetworkStrings +import com.revenuecat.purchases.utils.Result import java.security.SecureRandom class SigningManager( @@ -16,7 +16,6 @@ class SigningManager( ) { private companion object { const val NONCE_BYTES_SIZE = 12 - const val SALT_BYTES_SIZE = 16 } fun shouldVerifyEndpoint(endpoint: Endpoint): Boolean { @@ -29,12 +28,11 @@ class SigningManager( return String(Base64.encode(bytes, Base64.DEFAULT)) } - @Suppress("LongParameterList", "ReturnCount") + @Suppress("LongParameterList", "ReturnCount", "CyclomaticComplexMethod") fun verifyResponse( urlPath: String, - responseCode: Int, - signature: String?, - nonce: String, + signatureString: String?, + nonce: String?, body: String?, requestTime: String?, eTag: String?, @@ -43,9 +41,10 @@ class SigningManager( warnLog("Forcing signing error for request with path: $urlPath") return VerificationResult.FAILED } - val signatureVerifier = signatureVerificationMode.verifier ?: return VerificationResult.NOT_REQUESTED + val intermediateSignatureHelper = signatureVerificationMode.intermediateSignatureHelper + ?: return VerificationResult.NOT_REQUESTED - if (signature == null) { + if (signatureString == null) { errorLog(NetworkStrings.VERIFICATION_MISSING_SIGNATURE.format(urlPath)) return VerificationResult.FAILED } @@ -53,29 +52,45 @@ class SigningManager( errorLog(NetworkStrings.VERIFICATION_MISSING_REQUEST_TIME.format(urlPath)) return VerificationResult.FAILED } - - val signatureMessage = getSignatureMessage(responseCode, body, eTag) - if (signatureMessage == null) { + if (body == null && eTag == null) { errorLog(NetworkStrings.VERIFICATION_MISSING_BODY_OR_ETAG.format(urlPath)) return VerificationResult.FAILED } - val decodedNonce = Base64.decode(nonce, Base64.DEFAULT) - val decodedSignature = Base64.decode(signature, Base64.DEFAULT) - val saltBytes = decodedSignature.copyOfRange(0, SALT_BYTES_SIZE) - val signatureToVerify = decodedSignature.copyOfRange(SALT_BYTES_SIZE, decodedSignature.size) - val messageToVerify = saltBytes + decodedNonce + requestTime.toByteArray() + signatureMessage.toByteArray() - val verificationResult = signatureVerifier.verify(signatureToVerify, messageToVerify) - - return if (verificationResult) { - VerificationResult.VERIFIED - } else { - errorLog(NetworkStrings.VERIFICATION_ERROR.format(urlPath)) - VerificationResult.FAILED + val signature: Signature + try { + signature = Signature.fromString(signatureString) + } catch (e: InvalidSignatureSizeException) { + errorLog(NetworkStrings.VERIFICATION_INVALID_SIZE.format(urlPath, e.message)) + return VerificationResult.FAILED } - } - private fun getSignatureMessage(responseCode: Int, body: String?, eTag: String?): String? { - return if (responseCode == RCHTTPStatusCodes.NOT_MODIFIED) eTag else body + when (val result = intermediateSignatureHelper.createIntermediateKeyVerifierIfVerified(signature)) { + is Result.Error -> { + errorLog( + NetworkStrings.VERIFICATION_INTERMEDIATE_KEY_FAILED.format( + urlPath, + result.value.underlyingErrorMessage, + ), + ) + return VerificationResult.FAILED + } + is Result.Success -> { + val intermediateKeyVerifier = result.value + val decodedNonce = nonce?.let { Base64.decode(it, Base64.DEFAULT) } ?: byteArrayOf() + val requestTimeBytes = requestTime.toByteArray() + val eTagBytes = eTag?.toByteArray() ?: byteArrayOf() + val payloadBytes = body?.toByteArray() ?: byteArrayOf() + val messageToVerify = signature.salt + decodedNonce + requestTimeBytes + eTagBytes + payloadBytes + val verificationResult = intermediateKeyVerifier.verify(signature.payload, messageToVerify) + + return if (verificationResult) { + VerificationResult.VERIFIED + } else { + errorLog(NetworkStrings.VERIFICATION_ERROR.format(urlPath)) + VerificationResult.FAILED + } + } + } } } diff --git a/common/src/main/java/com/revenuecat/purchases/strings/NetworkStrings.kt b/common/src/main/java/com/revenuecat/purchases/strings/NetworkStrings.kt index b77719aa62..56411acb2f 100644 --- a/common/src/main/java/com/revenuecat/purchases/strings/NetworkStrings.kt +++ b/common/src/main/java/com/revenuecat/purchases/strings/NetworkStrings.kt @@ -11,9 +11,12 @@ object NetworkStrings { const val SAME_CALL_ALREADY_IN_PROGRESS = "Same call already in progress, adding to callbacks map with key: %s" const val PROBLEM_CONNECTING = "Unable to start a network connection due to a network configuration issue: %s" const val VERIFICATION_MISSING_SIGNATURE = "Verification: Request to '%s' requires a signature but none provided." + const val VERIFICATION_INTERMEDIATE_KEY_FAILED = "Verification: Request to '%s' provided an intermediate key that" + + " did not verify correctly. Reason %s" const val VERIFICATION_MISSING_REQUEST_TIME = "Verification: Request to '%s' requires a request time" + " but none provided." const val VERIFICATION_MISSING_BODY_OR_ETAG = "Verification: Request to '%s' requires a body or etag" + " but none provided." + const val VERIFICATION_INVALID_SIZE = "Verification: Request to '%s' has signature with wrong size. '%s'" const val VERIFICATION_ERROR = "Verification: Request to '%s' failed verification." } diff --git a/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt b/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt index 64fb4a5640..9a63490a69 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt @@ -41,7 +41,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { ) every { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } returns VerificationResult.VERIFIED client.performRequest( @@ -81,7 +81,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { assertThat(result.verificationResult).isEqualTo(VerificationResult.NOT_REQUESTED) verify(exactly = 0) { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } } @@ -112,7 +112,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { assertThat(result.verificationResult).isEqualTo(VerificationResult.NOT_REQUESTED) verify(exactly = 0) { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } } @@ -143,7 +143,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { assertThat(result.verificationResult).isEqualTo(VerificationResult.NOT_REQUESTED) verify(exactly = 0) { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } } @@ -157,7 +157,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { val responseCode = expectedResult.responseCode every { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } returns VerificationResult.VERIFIED every { @@ -176,7 +176,6 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { .setResponseCode(responseCode) .setHeader(HTTPResult.SIGNATURE_HEADER_NAME, "test-signature") .setHeader(HTTPResult.REQUEST_TIME_HEADER_NAME, 1234567890L) - .setHeader(HTTPResult.ETAG_HEADER_NAME, "test-etag") server.enqueue(response) val result = client.performRequest( @@ -192,12 +191,11 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { verify(exactly = 1) { mockSigningManager.verifyResponse( endpoint.getPath(), - responseCode, "test-signature", "test-nonce", "{\"test-key\":\"test-value\"}", "1234567890", - "test-etag" + eTag = null ) } } @@ -222,7 +220,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { server.takeRequest() assertThat(result.verificationResult).isEqualTo(VerificationResult.NOT_REQUESTED) verify(exactly = 0) { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } } @@ -236,7 +234,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { ) every { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } returns VerificationResult.FAILED val result = client.performRequest( @@ -261,7 +259,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { ) every { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } returns VerificationResult.FAILED var thrownCorrectException = false @@ -292,7 +290,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { ) every { - mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any(), any()) + mockSigningManager.verifyResponse(any(), any(), any(), any(), any(), any()) } returns VerificationResult.VERIFIED val result = client.performRequest( diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt index 242eacc42e..848b147d08 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt @@ -32,4 +32,15 @@ class SignatureVerificationModeTest { assertThat(SignatureVerificationMode.Informational(signatureVerifier).shouldVerify).isTrue assertThat(SignatureVerificationMode.Enforced(signatureVerifier).shouldVerify).isTrue } + + @Test + fun `intermediateSignatureHelper has values in enabled verification modes`() { + val signatureVerifier = DefaultSignatureVerifier() + var verificationMode: SignatureVerificationMode = SignatureVerificationMode.Disabled + assertThat(verificationMode.intermediateSignatureHelper).isNull() + verificationMode = SignatureVerificationMode.Informational(signatureVerifier) + assertThat(verificationMode.intermediateSignatureHelper).isNotNull + verificationMode = SignatureVerificationMode.Enforced(signatureVerifier) + assertThat(verificationMode.intermediateSignatureHelper).isNotNull + } } diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt index f5ca5e7676..0311afaa95 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt @@ -5,7 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.networking.Endpoint -import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes +import com.revenuecat.purchases.utils.Result import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat @@ -17,8 +17,9 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) class SigningManagerTest { - private lateinit var verifier: SignatureVerifier + private lateinit var intermediateKeyVerifier: SignatureVerifier private lateinit var appConfig: AppConfig + private lateinit var intermediateSignatureHelper: IntermediateSignatureHelper private lateinit var disabledSigningManager: SigningManager private lateinit var informationalSigningManager: SigningManager @@ -26,14 +27,21 @@ class SigningManagerTest { @Before fun setUp() { - verifier = mockk() appConfig = mockk().apply { every { forceSigningErrors } returns false } + intermediateKeyVerifier = mockk() + intermediateSignatureHelper = mockk().apply { + every { createIntermediateKeyVerifierIfVerified(any()) } returns Result.Success(intermediateKeyVerifier) + } disabledSigningManager = SigningManager(SignatureVerificationMode.Disabled, appConfig) - informationalSigningManager = SigningManager(SignatureVerificationMode.Informational(verifier), appConfig) - enforcedSigningManager = SigningManager(SignatureVerificationMode.Enforced(verifier), appConfig) + informationalSigningManager = SigningManager( + SignatureVerificationMode.Informational(mockk(), intermediateSignatureHelper), appConfig + ) + enforcedSigningManager = SigningManager( + SignatureVerificationMode.Enforced(mockk(), intermediateSignatureHelper), appConfig + ) } // region shouldVerifyEndpoint @@ -126,7 +134,7 @@ class SigningManagerTest { fun `verifyResponse returns error if status code not modified and etag is empty`() { val verificationResult = callVerifyResponse( informationalSigningManager, - responseCode = RCHTTPStatusCodes.NOT_MODIFIED, + body = null, eTag = null ) assertThat(verificationResult).isEqualTo(VerificationResult.FAILED) @@ -134,10 +142,9 @@ class SigningManagerTest { @Test fun `verifyResponse returns success if verifier returns success for not modified `() { - every { verifier.verify(any(), any()) } returns true + every { intermediateKeyVerifier.verify(any(), any()) } returns true val verificationResult = callVerifyResponse( informationalSigningManager, - responseCode = RCHTTPStatusCodes.NOT_MODIFIED, body = null, eTag = "test-etag" ) @@ -146,24 +153,22 @@ class SigningManagerTest { @Test fun `verifyResponse returns success if verifier returns success for given parameters`() { - every { verifier.verify(any(), any()) } returns true + every { intermediateKeyVerifier.verify(any(), any()) } returns true val verificationResult = callVerifyResponse(informationalSigningManager) assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) } @Test fun `verifyResponse returns error if verifier returns success for given parameters`() { - every { verifier.verify(any(), any()) } returns false + every { intermediateKeyVerifier.verify(any(), any()) } returns false val verificationResult = callVerifyResponse(informationalSigningManager) assertThat(verificationResult).isEqualTo(VerificationResult.FAILED) } @Test fun `verifyResponse with real data verifies correctly`() { - val signingManager = SigningManager( - SignatureVerificationMode.Informational(DefaultSignatureVerifier()), - appConfig - ) + val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") + val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) val verificationResult = callVerifyResponse(signingManager) assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) } @@ -171,10 +176,8 @@ class SigningManagerTest { @Suppress("MaxLineLength") @Test fun `verifyResponse with slightly different data does not verify correctly`() { - val signingManager = SigningManager( - SignatureVerificationMode.Informational(DefaultSignatureVerifier()), - appConfig, - ) + val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") + val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) assertThat( callVerifyResponse(signingManager, requestTime = "1677005916011") // Wrong request time ).isEqualTo(VerificationResult.FAILED) @@ -191,7 +194,7 @@ class SigningManagerTest { @Test fun `verifyResponse returns success for enforced mode if verifier returns success for given parameters`() { - every { verifier.verify(any(), any()) } returns true + every { intermediateKeyVerifier.verify(any(), any()) } returns true val verificationResult = callVerifyResponse(enforcedSigningManager) assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) } @@ -203,13 +206,12 @@ class SigningManagerTest { private fun callVerifyResponse( signingManager: SigningManager, requestPath: String = "test-url-path", - responseCode: Int = RCHTTPStatusCodes.SUCCESS, - signature: String? = "2bm3QppRywK5ULyCRLS5JJy9sq+84IkMk0Ue4LsywEp87t0tDObpzPlu30l4Desq9X65UFuosqwCLMizruDHbKvPqQLce0hrIuZpgic+cQ8=", + signature: String? = "xoDYyUeHnIlSIAeOOzmvdNPOlbNSKK+xE0fE/ufS1fsKCgoKkY+e/hYWiSW5cV6pVpp0i3Ag1p/wH4CcPnDSuG4qzPW8l582Q3gE9j5pIG3XrYxpblHCxBnfcBsxNriK0awxAd8tsYi90CIUqiJXxN+/9Z4xLNME00pcLbsXFO0GqNVPkYgGgSJWEd/xAIiXQBypugaAb17y4u0xpjcyS5JRFXuLJCD4CGMZtmqWewWuuQgC", nonce: String = "MTIzNDU2Nzg5MGFi", body: String? = "{\"request_date\":\"2023-02-21T18:58:36Z\",\"request_date_ms\":1677005916011,\"subscriber\":{\"entitlements\":{},\"first_seen\":\"2023-02-21T18:58:35Z\",\"last_seen\":\"2023-02-21T18:58:35Z\",\"management_url\":null,\"non_subscriptions\":{},\"original_app_user_id\":\"login\",\"original_application_version\":null,\"original_purchase_date\":null,\"other_purchases\":{},\"subscriptions\":{}}}\n", requestTime: String? = "1677005916012", eTag: String? = null - ) = signingManager.verifyResponse(requestPath, responseCode, signature, nonce, body, requestTime, eTag) + ) = signingManager.verifyResponse(requestPath, signature, nonce, body, requestTime, eTag) // endregion } From a6cbd9a007b9246b47ee32ae1511cedbfd8e959e Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 29 Jun 2023 12:43:10 +0200 Subject: [PATCH 2/9] Add comment specifying test keys used to generate signature --- .../purchases/common/verification/SigningManagerTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt index 0311afaa95..24bfaf5f42 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt @@ -206,6 +206,11 @@ class SigningManagerTest { private fun callVerifyResponse( signingManager: SigningManager, requestPath: String = "test-url-path", + // Generated signature using test keys: + // Test root private key: YMHMQMpepBKamtSzO8KCN2M8Z3AUW5R1JXIFtxUWFUI + // Test root public key: yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo= + // Test intermediate private key: fPBoIjQ7DecE89ATW6PZsqLVQNyEs5fiX3sUyS3U4YI + // Test intermediate public key: xoDYyUeHnIlSIAeOOzmvdNPOlbNSKK+xE0fE/ufS1fs= signature: String? = "xoDYyUeHnIlSIAeOOzmvdNPOlbNSKK+xE0fE/ufS1fsKCgoKkY+e/hYWiSW5cV6pVpp0i3Ag1p/wH4CcPnDSuG4qzPW8l582Q3gE9j5pIG3XrYxpblHCxBnfcBsxNriK0awxAd8tsYi90CIUqiJXxN+/9Z4xLNME00pcLbsXFO0GqNVPkYgGgSJWEd/xAIiXQBypugaAb17y4u0xpjcyS5JRFXuLJCD4CGMZtmqWewWuuQgC", nonce: String = "MTIzNDU2Nzg5MGFi", body: String? = "{\"request_date\":\"2023-02-21T18:58:36Z\",\"request_date_ms\":1677005916011,\"subscriber\":{\"entitlements\":{},\"first_seen\":\"2023-02-21T18:58:35Z\",\"last_seen\":\"2023-02-21T18:58:35Z\",\"management_url\":null,\"non_subscriptions\":{},\"original_app_user_id\":\"login\",\"original_application_version\":null,\"original_purchase_date\":null,\"other_purchases\":{},\"subscriptions\":{}}}\n", From f530431fbbac1f1c72e7c888f364ee271436a970 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 29 Jun 2023 13:16:58 +0200 Subject: [PATCH 3/9] Add more tests --- .../common/verification/SigningManagerTest.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt index 24bfaf5f42..0e3c0f5fcd 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt @@ -2,6 +2,8 @@ package com.revenuecat.purchases.common.verification import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.networking.Endpoint @@ -141,7 +143,16 @@ class SigningManagerTest { } @Test - fun `verifyResponse returns success if verifier returns success for not modified `() { + fun `verifyResponse returns error if failed to verify intermediate key`() { + every { + intermediateSignatureHelper.createIntermediateKeyVerifierIfVerified(any()) + } returns Result.Error(PurchasesError(PurchasesErrorCode.SignatureVerificationError)) + val verificationResult = callVerifyResponse(informationalSigningManager) + assertThat(verificationResult).isEqualTo(VerificationResult.FAILED) + } + + @Test + fun `verifyResponse returns success if intermediate key verifier returns success for not modified `() { every { intermediateKeyVerifier.verify(any(), any()) } returns true val verificationResult = callVerifyResponse( informationalSigningManager, @@ -152,14 +163,14 @@ class SigningManagerTest { } @Test - fun `verifyResponse returns success if verifier returns success for given parameters`() { + fun `verifyResponse returns success if intermediate key verifier returns success for given parameters`() { every { intermediateKeyVerifier.verify(any(), any()) } returns true val verificationResult = callVerifyResponse(informationalSigningManager) assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) } @Test - fun `verifyResponse returns error if verifier returns success for given parameters`() { + fun `verifyResponse returns error if intermediate key verifier returns error for given parameters`() { every { intermediateKeyVerifier.verify(any(), any()) } returns false val verificationResult = callVerifyResponse(informationalSigningManager) assertThat(verificationResult).isEqualTo(VerificationResult.FAILED) From 3e83d5b9d5c0afae3370c8bfaf8a2be451de5749 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 29 Jun 2023 16:59:55 +0200 Subject: [PATCH 4/9] Add tests for signing with both etag and payload --- .../common/HTTPClientVerificationTest.kt | 3 ++- .../common/verification/SigningManagerTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt b/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt index 9a63490a69..4e2ed7c574 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/HTTPClientVerificationTest.kt @@ -176,6 +176,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { .setResponseCode(responseCode) .setHeader(HTTPResult.SIGNATURE_HEADER_NAME, "test-signature") .setHeader(HTTPResult.REQUEST_TIME_HEADER_NAME, 1234567890L) + .setHeader(HTTPResult.ETAG_HEADER_NAME, "test-etag") server.enqueue(response) val result = client.performRequest( @@ -195,7 +196,7 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() { "test-nonce", "{\"test-key\":\"test-value\"}", "1234567890", - eTag = null + "test-etag" ) } } diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt index 0e3c0f5fcd..b8de3eb44f 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.common.verification import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.crypto.tink.subtle.Ed25519Sign import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.VerificationResult @@ -11,10 +12,13 @@ import com.revenuecat.purchases.utils.Result import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat +import org.bouncycastle.util.test.NumberParsing import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config +import java.nio.ByteBuffer +import java.security.SecureRandom @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) @@ -184,6 +188,19 @@ class SigningManagerTest { assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) } + @Test + fun `verifyResponse with both payload and etag verifies correctly`() { +// createFakeSignature() + val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") + val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) + val verificationResult = callVerifyResponse( + signingManager, + signature = "xoDYyUeHnIlSIAeOOzmvdNPOlbNSKK+xE0fE/ufS1fsKCgoKkY+e/hYWiSW5cV6pVpp0i3Ag1p/wH4CcPnDSuG4qzPW8l582Q3gE9j5pIG3XrYxpblHCxBnfcBsxNriK0awxAVBTK1hPk60bAeilLRVB2Qwj2phUfQKOqgKxxUh1XHQlJJqUOiAUdv35dB3tmtPWH//MHO/V7mD72P+kwqecZgDxEZxgbe68VczwjIkSPo4F", + eTag = "test-etag", + ) + assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) + } + @Suppress("MaxLineLength") @Test fun `verifyResponse with slightly different data does not verify correctly`() { From 4a127744a3d8f815d810439fde20eef27dc90cf6 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 29 Jun 2023 17:02:27 +0200 Subject: [PATCH 5/9] Whoops --- .../purchases/common/verification/SigningManagerTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt index b8de3eb44f..b979391c0d 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt @@ -190,7 +190,6 @@ class SigningManagerTest { @Test fun `verifyResponse with both payload and etag verifies correctly`() { -// createFakeSignature() val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) val verificationResult = callVerifyResponse( From 05331304816690b8b6a9722148dccfd282969e03 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Thu, 29 Jun 2023 17:53:58 +0200 Subject: [PATCH 6/9] Remove uneeded field from verification mode enum --- .../verification/SignatureVerificationMode.kt | 10 ++++---- .../SignatureVerificationModeTest.kt | 10 ++++---- .../common/verification/SigningManagerTest.kt | 23 +++++++++++-------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt index dd54903859..6ef1cfae23 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt @@ -6,12 +6,12 @@ sealed class SignatureVerificationMode { companion object { fun fromEntitlementVerificationMode( verificationMode: EntitlementVerificationMode, - signatureVerifier: SignatureVerifier? = null, + rootVerifier: SignatureVerifier? = null, ): SignatureVerificationMode { return when (verificationMode) { EntitlementVerificationMode.DISABLED -> Disabled EntitlementVerificationMode.INFORMATIONAL -> - Informational(signatureVerifier ?: DefaultSignatureVerifier()) + Informational(IntermediateSignatureHelper(rootVerifier ?: DefaultSignatureVerifier())) // Hidden ENFORCED mode during feature beta // EntitlementVerificationMode.ENFORCED -> // Enforced(signatureVerifier ?: DefaultSignatureVerifier()) @@ -20,15 +20,13 @@ sealed class SignatureVerificationMode { } object Disabled : SignatureVerificationMode() data class Informational( - val signatureVerifier: SignatureVerifier, override val intermediateSignatureHelper: IntermediateSignatureHelper = IntermediateSignatureHelper( - signatureVerifier, + DefaultSignatureVerifier(), ), ) : SignatureVerificationMode() data class Enforced( - val signatureVerifier: SignatureVerifier, override val intermediateSignatureHelper: IntermediateSignatureHelper = IntermediateSignatureHelper( - signatureVerifier, + DefaultSignatureVerifier(), ), ) : SignatureVerificationMode() diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt index 848b147d08..c7f2fe6832 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SignatureVerificationModeTest.kt @@ -27,20 +27,18 @@ class SignatureVerificationModeTest { @Test fun `shouldVerify has correct values for all the verification modes`() { - val signatureVerifier = DefaultSignatureVerifier() assertThat(SignatureVerificationMode.Disabled.shouldVerify).isFalse - assertThat(SignatureVerificationMode.Informational(signatureVerifier).shouldVerify).isTrue - assertThat(SignatureVerificationMode.Enforced(signatureVerifier).shouldVerify).isTrue + assertThat(SignatureVerificationMode.Informational().shouldVerify).isTrue + assertThat(SignatureVerificationMode.Enforced().shouldVerify).isTrue } @Test fun `intermediateSignatureHelper has values in enabled verification modes`() { - val signatureVerifier = DefaultSignatureVerifier() var verificationMode: SignatureVerificationMode = SignatureVerificationMode.Disabled assertThat(verificationMode.intermediateSignatureHelper).isNull() - verificationMode = SignatureVerificationMode.Informational(signatureVerifier) + verificationMode = SignatureVerificationMode.Informational() assertThat(verificationMode.intermediateSignatureHelper).isNotNull - verificationMode = SignatureVerificationMode.Enforced(signatureVerifier) + verificationMode = SignatureVerificationMode.Enforced() assertThat(verificationMode.intermediateSignatureHelper).isNotNull } } diff --git a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt index b979391c0d..35b750c7be 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/verification/SigningManagerTest.kt @@ -2,7 +2,6 @@ package com.revenuecat.purchases.common.verification import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.crypto.tink.subtle.Ed25519Sign import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.VerificationResult @@ -12,13 +11,10 @@ import com.revenuecat.purchases.utils.Result import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat -import org.bouncycastle.util.test.NumberParsing import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config -import java.nio.ByteBuffer -import java.security.SecureRandom @RunWith(AndroidJUnit4::class) @Config(manifest = Config.NONE) @@ -43,10 +39,10 @@ class SigningManagerTest { disabledSigningManager = SigningManager(SignatureVerificationMode.Disabled, appConfig) informationalSigningManager = SigningManager( - SignatureVerificationMode.Informational(mockk(), intermediateSignatureHelper), appConfig + SignatureVerificationMode.Informational(intermediateSignatureHelper), appConfig ) enforcedSigningManager = SigningManager( - SignatureVerificationMode.Enforced(mockk(), intermediateSignatureHelper), appConfig + SignatureVerificationMode.Enforced(intermediateSignatureHelper), appConfig ) } @@ -183,7 +179,10 @@ class SigningManagerTest { @Test fun `verifyResponse with real data verifies correctly`() { val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") - val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) + val signingManager = SigningManager( + SignatureVerificationMode.Informational(IntermediateSignatureHelper(rootVerifier)), + appConfig, + ) val verificationResult = callVerifyResponse(signingManager) assertThat(verificationResult).isEqualTo(VerificationResult.VERIFIED) } @@ -191,7 +190,10 @@ class SigningManagerTest { @Test fun `verifyResponse with both payload and etag verifies correctly`() { val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") - val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) + val signingManager = SigningManager( + SignatureVerificationMode.Informational(IntermediateSignatureHelper(rootVerifier)), + appConfig, + ) val verificationResult = callVerifyResponse( signingManager, signature = "xoDYyUeHnIlSIAeOOzmvdNPOlbNSKK+xE0fE/ufS1fsKCgoKkY+e/hYWiSW5cV6pVpp0i3Ag1p/wH4CcPnDSuG4qzPW8l582Q3gE9j5pIG3XrYxpblHCxBnfcBsxNriK0awxAVBTK1hPk60bAeilLRVB2Qwj2phUfQKOqgKxxUh1XHQlJJqUOiAUdv35dB3tmtPWH//MHO/V7mD72P+kwqecZgDxEZxgbe68VczwjIkSPo4F", @@ -204,7 +206,10 @@ class SigningManagerTest { @Test fun `verifyResponse with slightly different data does not verify correctly`() { val rootVerifier = DefaultSignatureVerifier("yg2wZGAr8Af+Unt9RImQDbL7qA81txk+ga0I+ylmcyo=") - val signingManager = SigningManager(SignatureVerificationMode.Informational(rootVerifier), appConfig) + val signingManager = SigningManager( + SignatureVerificationMode.Informational(IntermediateSignatureHelper(rootVerifier)), + appConfig, + ) assertThat( callVerifyResponse(signingManager, requestTime = "1677005916011") // Wrong request time ).isEqualTo(VerificationResult.FAILED) From 291fc74254b359c9c3b134d46024aa93b0f2ff16 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Fri, 30 Jun 2023 09:26:57 +0200 Subject: [PATCH 7/9] Extract createIntermediateSignatureHelper --- .../verification/SignatureVerificationMode.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt index 6ef1cfae23..1f8ba1d660 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt @@ -17,17 +17,17 @@ sealed class SignatureVerificationMode { // Enforced(signatureVerifier ?: DefaultSignatureVerifier()) } } + + private fun createIntermediateSignatureHelper(): IntermediateSignatureHelper { + return IntermediateSignatureHelper(DefaultSignatureVerifier()) + } } object Disabled : SignatureVerificationMode() data class Informational( - override val intermediateSignatureHelper: IntermediateSignatureHelper = IntermediateSignatureHelper( - DefaultSignatureVerifier(), - ), + override val intermediateSignatureHelper: IntermediateSignatureHelper = createIntermediateSignatureHelper(), ) : SignatureVerificationMode() data class Enforced( - override val intermediateSignatureHelper: IntermediateSignatureHelper = IntermediateSignatureHelper( - DefaultSignatureVerifier(), - ), + override val intermediateSignatureHelper: IntermediateSignatureHelper = createIntermediateSignatureHelper(), ) : SignatureVerificationMode() val shouldVerify: Boolean @@ -46,4 +46,6 @@ sealed class SignatureVerificationMode { is Informational -> intermediateSignatureHelper is Enforced -> intermediateSignatureHelper } + + } From c5249fde1e48dbf068481dfe944f16a32775509c Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Fri, 30 Jun 2023 10:59:33 +0200 Subject: [PATCH 8/9] Fix detekt --- .../purchases/common/verification/SignatureVerificationMode.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt index 1f8ba1d660..abbff31cfb 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/verification/SignatureVerificationMode.kt @@ -46,6 +46,4 @@ sealed class SignatureVerificationMode { is Informational -> intermediateSignatureHelper is Enforced -> intermediateSignatureHelper } - - } From 589dbb8fc11b237e557bdf2946369d37b9a279a0 Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Fri, 30 Jun 2023 11:13:36 +0200 Subject: [PATCH 9/9] Do not use etags if cache version not requested and verification enabled --- .../revenuecat/purchases/common/HTTPClient.kt | 5 +- .../common/networking/ETagManager.kt | 17 ++-- .../purchases/common/BaseHTTPClientTest.kt | 2 +- .../purchases/common/HTTPClientTest.kt | 8 +- .../common/networking/ETagManagerTest.kt | 90 +++++++++++++++++-- 5 files changed, 103 insertions(+), 19 deletions(-) diff --git a/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt b/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt index 99bd57219d..fcd73f9581 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/HTTPClient.kt @@ -147,7 +147,7 @@ class HTTPClient( val fullURL = URL(baseURL, urlPathWithVersion) nonce = if (shouldSignResponse) signingManager.createRandomNonce() else null - val headers = getHeaders(requestHeaders, urlPathWithVersion, refreshETag, nonce) + val headers = getHeaders(requestHeaders, urlPathWithVersion, refreshETag, nonce, shouldSignResponse) val httpRequest = HTTPRequest(fullURL, headers, jsonBody) @@ -237,6 +237,7 @@ class HTTPClient( urlPath: String, refreshETag: Boolean, nonce: String?, + shouldSignResponse: Boolean, ): Map { return mapOf( "Content-Type" to "application/json", @@ -252,7 +253,7 @@ class HTTPClient( "X-Nonce" to nonce, ) .plus(authenticationHeaders) - .plus(eTagManager.getETagHeaders(urlPath, refreshETag)) + .plus(eTagManager.getETagHeaders(urlPath, shouldSignResponse, refreshETag)) .filterNotNullValues() } diff --git a/common/src/main/java/com/revenuecat/purchases/common/networking/ETagManager.kt b/common/src/main/java/com/revenuecat/purchases/common/networking/ETagManager.kt index d949edf1f6..944236aeb4 100644 --- a/common/src/main/java/com/revenuecat/purchases/common/networking/ETagManager.kt +++ b/common/src/main/java/com/revenuecat/purchases/common/networking/ETagManager.kt @@ -55,9 +55,11 @@ class ETagManager( internal fun getETagHeaders( path: String, + verificationRequested: Boolean, refreshETag: Boolean = false, ): Map { - val eTagData = if (refreshETag) null else getETagData(path) + val storedResult = if (refreshETag) null else getStoredResultSavedInSharedPreferences(path) + val eTagData = storedResult?.eTagData?.takeIf { shouldUseETag(storedResult, verificationRequested) } return mapOf( HTTPRequest.ETAG_HEADER_NAME to eTagData?.eTag.orEmpty(), HTTPRequest.ETAG_LAST_REFRESH_NAME to eTagData?.lastRefreshTime?.time?.toString(), @@ -146,10 +148,6 @@ class ETagManager( } } - private fun getETagData(path: String): ETagData? { - return getStoredResultSavedInSharedPreferences(path)?.eTagData - } - private fun shouldStoreBackendResult(resultFromBackend: HTTPResult): Boolean { val responseCode = resultFromBackend.responseCode return responseCode != RCHTTPStatusCodes.NOT_MODIFIED && @@ -157,6 +155,15 @@ class ETagManager( resultFromBackend.verificationResult != VerificationResult.FAILED } + private fun shouldUseETag(storedResult: HTTPResultWithETag, verificationRequested: Boolean): Boolean { + return when (storedResult.httpResult.verificationResult) { + VerificationResult.VERIFIED -> true + VerificationResult.NOT_REQUESTED -> !verificationRequested + // Should never happen since we don't store these verification results in the cache + VerificationResult.FAILED, VerificationResult.VERIFIED_ON_DEVICE -> false + } + } + companion object { fun initializeSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( diff --git a/common/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt b/common/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt index 33c3af1454..42d842f7c2 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/BaseHTTPClientTest.kt @@ -39,7 +39,7 @@ abstract class BaseHTTPClientTest { protected val mockETagManager = mockk().also { every { - it.getETagHeaders(any(), any()) + it.getETagHeaders(any(), any(), any()) } answers { mapOf(HTTPRequest.ETAG_HEADER_NAME to "") } diff --git a/common/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt b/common/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt index ef103ecd22..91279ac49d 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/HTTPClientTest.kt @@ -195,7 +195,7 @@ class HTTPClientTest: BaseHTTPClientTest() { val endpoint = Endpoint.LogIn every { - mockETagManager.getETagHeaders(any(), any()) + mockETagManager.getETagHeaders(any(), any(), any()) } answers { mapOf( HTTPRequest.ETAG_HEADER_NAME to "mock-etag", @@ -222,7 +222,7 @@ class HTTPClientTest: BaseHTTPClientTest() { val endpoint = Endpoint.LogIn every { - mockETagManager.getETagHeaders(any(), any()) + mockETagManager.getETagHeaders(any(), any(), any()) } answers { mapOf( HTTPRequest.ETAG_HEADER_NAME to "mock-etag", @@ -365,10 +365,10 @@ class HTTPClientTest: BaseHTTPClientTest() { server.takeRequest() verify(exactly = 1) { - mockETagManager.getETagHeaders(any(), false) + mockETagManager.getETagHeaders(any(), any(), refreshETag = false) } verify(exactly = 1) { - mockETagManager.getETagHeaders(any(), true) + mockETagManager.getETagHeaders(any(), any(), refreshETag = true) } assertThat(result.payload).isEqualTo(expectedResult.payload) assertThat(result.responseCode).isEqualTo(expectedResult.responseCode) diff --git a/common/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt b/common/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt index 2f9574f3a9..9d78da3a31 100644 --- a/common/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt +++ b/common/src/test/java/com/revenuecat/purchases/common/networking/ETagManagerTest.kt @@ -56,7 +56,7 @@ class ETagManagerTest { val path = "/v1/subscribers/appUserID" mockCachedHTTPResult(expectedETag = null, path = path) - val eTagHeaders = underTest.getETagHeaders(path) + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] assertThat(eTagHeader).isNotNull assertThat(eTagHeader).isBlank @@ -67,7 +67,7 @@ class ETagManagerTest { val path = "/v1/subscribers/appUserID" mockCachedHTTPResult(expectedETag = null, path = path) - val eTagHeaders = underTest.getETagHeaders(path) + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) val lastRefreshTimeHeader = eTagHeaders[HTTPRequest.ETAG_LAST_REFRESH_NAME] assertThat(lastRefreshTimeHeader).isNull() } @@ -77,7 +77,7 @@ class ETagManagerTest { val path = "/v1/subscribers/appUserID" mockCachedHTTPResult(expectedETag = "etag", expectedLastRefreshTime = null, path = path) - val eTagHeaders = underTest.getETagHeaders(path) + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] assertThat(eTagHeader).isEqualTo("etag") @@ -91,7 +91,7 @@ class ETagManagerTest { val expectedETag = "etag" mockCachedHTTPResult(expectedETag, path) - val eTagHeaders = underTest.getETagHeaders(path) + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] assertThat(eTagHeader).isNotNull assertThat(eTagHeader).isEqualTo(expectedETag) @@ -102,7 +102,7 @@ class ETagManagerTest { val path = "/v1/subscribers/appUserID" mockCachedHTTPResult(expectedETag = "etag", expectedLastRefreshTime = testDate, path = path) - val eTagHeaders = underTest.getETagHeaders(path) + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) val lastRefreshTimeHeader = eTagHeaders[HTTPRequest.ETAG_LAST_REFRESH_NAME] assertThat(lastRefreshTimeHeader).isEqualTo("1675954145") } @@ -112,10 +112,86 @@ class ETagManagerTest { val path = "/v1/subscribers/appUserID" mockCachedHTTPResult(expectedETag = "etag", expectedLastRefreshTime = testDate, path = path) - val eTagHeaders = underTest.getETagHeaders(path) + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) assertThat(eTagHeaders.size).isEqualTo(2) } + // region ETag headers usage verification tests + + @Test + fun `ETag headers are added if cached result is verified and verification not requested`() { + val path = "/v1/subscribers/appUserID" + val expectedETag = "etag" + mockCachedHTTPResult( + expectedETag, + path, + httpResult = HTTPResult.createResult(origin = HTTPResult.Origin.CACHE, verificationResult = VERIFIED), + ) + + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) + val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] + assertThat(eTagHeader).isEqualTo(expectedETag) + } + + @Test + fun `ETag headers are added if cached result is not requested and verification not requested`() { + val path = "/v1/subscribers/appUserID" + val expectedETag = "etag" + mockCachedHTTPResult( + expectedETag, + path, + httpResult = HTTPResult.createResult(origin = HTTPResult.Origin.CACHE, verificationResult = NOT_REQUESTED), + ) + + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) + val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] + assertThat(eTagHeader).isEqualTo(expectedETag) + } + + @Test + fun `ETag headers are not added if cached result is not requested and verification requested`() { + val path = "/v1/subscribers/appUserID" + mockCachedHTTPResult( + "etag", + path, + httpResult = HTTPResult.createResult(origin = HTTPResult.Origin.CACHE, verificationResult = NOT_REQUESTED), + ) + + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = true) + val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] + assertThat(eTagHeader).isEqualTo("") + } + + @Test + fun `ETag headers are not added if cached result errored`() { + val path = "/v1/subscribers/appUserID" + mockCachedHTTPResult( + "etag", + path, + httpResult = HTTPResult.createResult(origin = HTTPResult.Origin.CACHE, verificationResult = FAILED), + ) + + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) + val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] + assertThat(eTagHeader).isEqualTo("") + } + + @Test + fun `ETag headers are not added if cached result verified on device`() { + val path = "/v1/subscribers/appUserID" + mockCachedHTTPResult( + "etag", + path, + httpResult = HTTPResult.createResult(origin = HTTPResult.Origin.CACHE, verificationResult = VERIFIED_ON_DEVICE), + ) + + val eTagHeaders = underTest.getETagHeaders(path, verificationRequested = false) + val eTagHeader = eTagHeaders[HTTPRequest.ETAG_HEADER_NAME] + assertThat(eTagHeader).isEqualTo("") + } + + // endregion ETag headers usage verification tests + @Test fun `If response code is 304, cached version should be used`() { val shouldUse = underTest.shouldUseCachedVersion(RCHTTPStatusCodes.NOT_MODIFIED) @@ -256,7 +332,7 @@ class ETagManagerTest { val path = "/v1/subscribers/appUserID" mockCachedHTTPResult(expectedETag = null, path = path) - val requestWithETagHeader = underTest.getETagHeaders(path, refreshETag = true) + val requestWithETagHeader = underTest.getETagHeaders(path, verificationRequested = false, refreshETag = true) val eTagHeader = requestWithETagHeader[HTTPRequest.ETAG_HEADER_NAME] assertThat(eTagHeader).isNotNull assertThat(eTagHeader).isBlank