Skip to content

Commit

Permalink
Trusted entitlements: New trusted entitlements signature format (#1117)
Browse files Browse the repository at this point in the history
### Description
Integration branch for the changes in trusted entitlements. Includes
changes from:
- #1111 
- #1114 
- #1118 
- #1119 
- #1124
  • Loading branch information
tonidero authored Jul 7, 2023
1 parent 982d3f8 commit e8a90a0
Show file tree
Hide file tree
Showing 13 changed files with 329 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ internal class PurchasesFactory(
val signatureVerificationMode = SignatureVerificationMode.fromEntitlementVerificationMode(
verificationMode,
)
val signingManager = SigningManager(signatureVerificationMode, appConfig)
val signingManager = SigningManager(signatureVerificationMode, appConfig, apiKey)

val httpClient = HTTPClient(appConfig, eTagManager, diagnosticsTracker, signingManager)
val backendHelper = BackendHelper(apiKey, dispatcher, appConfig, httpClient)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ internal 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 @@ internal class HTTPClient(
}

val verificationResult = if (shouldSignResponse &&
nonce != null &&
RCHTTPStatusCodes.isSuccessful(responseCode)
) {
verifyResponse(path, responseCode, connection, payload, nonce)
verifyResponse(urlPathWithVersion, connection, payload, nonce)
} else {
VerificationResult.NOT_REQUESTED
}
Expand Down Expand Up @@ -238,6 +237,7 @@ internal 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 @@ internal 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 @@ internal 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 @@ internal 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 @@ internal 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 {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,29 @@ internal 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())
// Hidden ENFORCED mode during feature beta
Informational(IntermediateSignatureHelper(rootVerifier ?: DefaultSignatureVerifier()))
// Hidden ENFORCED mode temporarily. Will be added back in the future.
// 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 @@ internal 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,18 +5,66 @@ 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

internal class SigningManager(
val signatureVerificationMode: SignatureVerificationMode,
val appConfig: AppConfig,
private val appConfig: AppConfig,
private val apiKey: String,
) {
private companion object {
const val NONCE_BYTES_SIZE = 12
const val SALT_BYTES_SIZE = 16
}

private data class Parameters(
val salt: ByteArray,
val apiKey: String,
val nonce: String?,
val urlPath: String,
val requestTime: String,
val eTag: String?,
val body: String?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as Parameters

if (!salt.contentEquals(other.salt)) return false
if (apiKey != other.apiKey) return false
if (nonce != other.nonce) return false
if (urlPath != other.urlPath) return false
if (requestTime != other.requestTime) return false
if (eTag != other.eTag) return false
if (body != other.body) return false

return true
}

override fun hashCode(): Int {
var result = salt.contentHashCode()
result = 31 * result + apiKey.hashCode()
result = 31 * result + (nonce?.hashCode() ?: 0)
result = 31 * result + urlPath.hashCode()
result = 31 * result + requestTime.hashCode()
result = 31 * result + (eTag?.hashCode() ?: 0)
result = 31 * result + (body?.hashCode() ?: 0)
return result
}

fun toSignatureToVerify(): ByteArray {
return salt +
apiKey.toByteArray() +
(nonce?.let { Base64.decode(it, Base64.DEFAULT) } ?: byteArrayOf()) +
urlPath.toByteArray() +
requestTime.toByteArray() +
(eTag?.toByteArray() ?: byteArrayOf()) +
(body?.toByteArray() ?: byteArrayOf())
}
}

fun shouldVerifyEndpoint(endpoint: Endpoint): Boolean {
Expand All @@ -29,12 +77,11 @@ internal class SigningManager(
return String(Base64.encode(bytes, Base64.DEFAULT))
}

@Suppress("LongParameterList", "ReturnCount")
@Suppress("LongParameterList", "ReturnCount", "CyclomaticComplexMethod", "LongMethod")
fun verifyResponse(
urlPath: String,
responseCode: Int,
signature: String?,
nonce: String,
signatureString: String?,
nonce: String?,
body: String?,
requestTime: String?,
eTag: String?,
Expand All @@ -43,39 +90,63 @@ internal 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 signatureParameters = Parameters(
signature.salt,
apiKey,
nonce,
urlPath,
requestTime,
eTag,
body,
)
val verificationResult = intermediateKeyVerifier.verify(
signature.payload,
signatureParameters.toSignatureToVerify(),
)

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 @@ internal 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 @@ -82,7 +82,7 @@ internal abstract class BaseBackendIntegrationTest {
every { edit() } returns sharedPreferencesEditor
}
eTagManager = ETagManager(sharedPreferences)
signingManager = SigningManager(SignatureVerificationMode.Disabled, appConfig)
signingManager = SigningManager(SignatureVerificationMode.Disabled, appConfig, apiKey())
httpClient = HTTPClient(appConfig, eTagManager, diagnosticsTrackerIfEnabled = null, signingManager)
backendHelper = BackendHelper(apiKey(), dispatcher, appConfig, httpClient)
backend = Backend(appConfig, dispatcher, diagnosticsDispatcher, httpClient, backendHelper)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ internal 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 @@ internal 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 @@ internal 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 @@ internal 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
Loading

0 comments on commit e8a90a0

Please sign in to comment.