Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- We strengthened Android cookie encryption by migrating from `AES/CBC/PKCS7Padding` to `AES/GCM/NoPadding`.

## [v0.4.0] - 2026-04-17

- We upgraded `@op-engineering/op-sqlite` from v15.0.7 to v15.2.5.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ package com.mendix.mendixnative.encryption
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Base64.DEFAULT
import androidx.annotation.RequiresApi
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.io.IOException
Expand All @@ -17,10 +15,15 @@ import java.security.Key
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec

private const val STORE_AES_KEY = "AES_KEY"
private const val encryptionTransformationName = "AES/CBC/PKCS7Padding"
private const val STORE_AES_KEY_V2 = "AES_KEY_V2"
private const val legacyEncryptionTransformationName = "AES/CBC/PKCS7Padding"
private const val modernEncryptionTransformationName = "AES/GCM/NoPadding"
private const val modernEncryptionVersionPrefix = "v2:"
private const val gcmTagLengthBits = 128

private var masterKey: MasterKey? = null
fun getMasterKey(context: Context): MasterKey {
Expand Down Expand Up @@ -48,27 +51,41 @@ fun getEncryptedSharedPreferences(
}

/**
* generates or returns an application wide AES key.
* returns an application wide AES key.
*
* @return Key
*/
@RequiresApi(Build.VERSION_CODES.M)
private fun getAESKey(): Key? {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (!keyStore.containsAlias(STORE_AES_KEY)) {
return if (keyStore.containsAlias(STORE_AES_KEY))
keyStore.getKey(STORE_AES_KEY, null)
else null
}

private fun getAESKeyV2(): Key? {
return getOrCreateAESKey(
STORE_AES_KEY_V2,
KeyProperties.BLOCK_MODE_GCM,
KeyProperties.ENCRYPTION_PADDING_NONE
)
}

private fun getOrCreateAESKey(alias: String, blockMode: String, encryptionPadding: String): Key? {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (!keyStore.containsAlias(alias)) {
val keyGenerator =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(
STORE_AES_KEY,
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7).build()
.setBlockModes(blockMode)
.setEncryptionPaddings(encryptionPadding).build()
)
keyGenerator.generateKey()
}
return keyStore.getKey(STORE_AES_KEY, null)
return keyStore.getKey(alias, null)
}

/**
Expand All @@ -79,13 +96,15 @@ private fun getAESKey(): Key? {
*/
fun encryptValue(
value: String,
@SuppressLint("NewApi", "LocalSuppress") getPassword: () -> Key? = { getAESKey() },
@SuppressLint("NewApi", "LocalSuppress") getPassword: () -> Key? = { getAESKeyV2() },
): Triple<ByteArray, ByteArray?, Boolean> {
val cipher = Cipher.getInstance(encryptionTransformationName)
val cipher = Cipher.getInstance(modernEncryptionTransformationName)
cipher.init(Cipher.ENCRYPT_MODE, getPassword())
val encryptedValue = cipher.doFinal(value.encodeToByteArray())
val versionedEncryptedValue =
"$modernEncryptionVersionPrefix${Base64.encodeToString(encryptedValue, DEFAULT)}"
return Triple(
Base64.encode(encryptedValue, DEFAULT),
versionedEncryptedValue.encodeToByteArray(),
Base64.encode(cipher.iv, DEFAULT),
true
)
Expand All @@ -101,9 +120,23 @@ fun encryptValue(
fun decryptValue(
value: String,
iv: String?,
@SuppressLint("NewApi", "LocalSuppress") getPassword: () -> Key? = { getAESKey() },
@SuppressLint("NewApi", "LocalSuppress") legacyGetPassword: () -> Key? = { getAESKey() },
@SuppressLint("NewApi", "LocalSuppress") modernGetPassword: () -> Key? = { getAESKeyV2() },
): String {
return if (value.startsWith(modernEncryptionVersionPrefix)) {
decryptModernValue(value.removePrefix(modernEncryptionVersionPrefix), iv, modernGetPassword)
} else {
decryptLegacyValue(value, iv, legacyGetPassword)
}
}

private fun decryptLegacyValue(
value: String,
iv: String?,
getPassword: () -> Key?,
): String {
val cipher = Cipher.getInstance(encryptionTransformationName)
requireNotNull(iv) { "Missing IV for legacy encrypted value." }
val cipher = Cipher.getInstance(legacyEncryptionTransformationName)
cipher.init(
Cipher.DECRYPT_MODE,
getPassword(),
Expand All @@ -112,3 +145,19 @@ fun decryptValue(
val unencryptedValue = cipher.doFinal(Base64.decode(value, DEFAULT))
return String(unencryptedValue, Charsets.UTF_8)
}

private fun decryptModernValue(
value: String,
iv: String?,
getPassword: () -> Key?,
): String {
requireNotNull(iv) { "Missing nonce for modern encrypted value." }
val cipher = Cipher.getInstance(modernEncryptionTransformationName)
cipher.init(
Cipher.DECRYPT_MODE,
getPassword(),
GCMParameterSpec(gcmTagLengthBits, Base64.decode(iv, DEFAULT))
)
val unencryptedValue = cipher.doFinal(Base64.decode(value, DEFAULT))
return String(unencryptedValue, Charsets.UTF_8)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mendix.mendixnative.request

import android.util.Log
import com.mendix.mendixnative.config.AppUrl
import com.mendix.mendixnative.encryption.decryptValue
import com.mendix.mendixnative.encryption.encryptValue
Expand Down Expand Up @@ -39,10 +40,14 @@ fun Request.withDecryptedCookies(): Request {
val (key, value) = it.split("=", limit = 2)

if (encryptedCookieExists!! && key.startsWith(encryptedCookieKeyPrefix)) {
val params = cookieValueToDecryptionParams(value)
val decryptedValue = decryptValue(params.first, params.second)

return@map "${key.removePrefix(encryptedCookieKeyPrefix)}=$decryptedValue"
try {
val params = cookieValueToDecryptionParams(value)
val decryptedValue = decryptValue(params.first, params.second)
return@map "${key.removePrefix(encryptedCookieKeyPrefix)}=$decryptedValue"
} catch (e: Exception) {
Log.w("MendixNetworkInterceptor", "Failed to decrypt cookie $key, dropping it", e)
return@map null
}
} else if (!encryptedCookieExists) {
return@map it;
}
Expand Down
Loading