Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Trusted entitlements: Do not use etags if cache version not requested and verification enabled #1114

Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -238,6 +237,7 @@ class HTTPClient(
urlPath: String,
refreshETag: Boolean,
nonce: String?,
shouldSignResponse: Boolean,
): Map<String, String> {
return mapOf(
"Content-Type" to "application/json",
Expand All @@ -253,7 +253,7 @@ class HTTPClient(
"X-Nonce" to nonce,
)
.plus(authenticationHeaders)
.plus(eTagManager.getETagHeaders(urlPath, refreshETag))
.plus(eTagManager.getETagHeaders(urlPath, shouldSignResponse, refreshETag))
.filterNotNullValues()
}

Expand All @@ -278,15 +278,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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,11 @@ class ETagManager(

internal fun getETagHeaders(
path: String,
verificationRequested: Boolean,
refreshETag: Boolean = false,
): Map<String, String?> {
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(),
Expand Down Expand Up @@ -146,17 +148,22 @@ 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 &&
responseCode < RCHTTPStatusCodes.ERROR &&
resultFromBackend.verificationResult != VerificationResult.FAILED
}

private fun shouldUseETag(storedResult: HTTPResultWithETag, verificationRequested: Boolean): Boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏🏻

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ll add this comment too 👍🏻

VerificationResult.FAILED, VerificationResult.VERIFIED_ON_DEVICE -> false
}
}

companion object {
fun initializeSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,29 @@ 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())
}
}

private fun createIntermediateSignatureHelper(): IntermediateSignatureHelper {
return IntermediateSignatureHelper(DefaultSignatureVerifier())
}
}
object Disabled : SignatureVerificationMode()
data class Informational(val signatureVerifier: SignatureVerifier) : SignatureVerificationMode()
data class Enforced(val signatureVerifier: SignatureVerifier) : SignatureVerificationMode()
data class Informational(
override val intermediateSignatureHelper: IntermediateSignatureHelper = createIntermediateSignatureHelper(),
) : SignatureVerificationMode()
data class Enforced(
override val intermediateSignatureHelper: IntermediateSignatureHelper = createIntermediateSignatureHelper(),
) : SignatureVerificationMode()

val shouldVerify: Boolean
get() = when (this) {
Expand All @@ -32,10 +40,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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -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?,
Expand All @@ -43,39 +41,56 @@ 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
}
if (requestTime == null) {
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
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ abstract class BaseHTTPClientTest {

protected val mockETagManager = mockk<ETagManager>().also {
every {
it.getETagHeaders(any(), any())
it.getETagHeaders(any(), any(), any())
} answers {
mapOf(HTTPRequest.ETAG_HEADER_NAME to "")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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())
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -192,7 +192,6 @@ class HTTPClientVerificationTest: BaseHTTPClientTest() {
verify(exactly = 1) {
mockSigningManager.verifyResponse(
endpoint.getPath(),
responseCode,
"test-signature",
"test-nonce",
"{\"test-key\":\"test-value\"}",
Expand Down Expand Up @@ -222,7 +221,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())
}
}

Expand All @@ -236,7 +235,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(
Expand All @@ -261,7 +260,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
Expand Down Expand Up @@ -292,7 +291,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(
Expand Down
Loading