From d7f71aaad584b2446919bdacd201c4f20af46978 Mon Sep 17 00:00:00 2001 From: Yogendra Shelke <25844542+YogendraShelke@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:52:55 +0530 Subject: [PATCH] feat: enhance cookie encryption handling with versioning and error logging --- .../encryption/MendixEncryptionToolkit.kt | 79 +++++++++++++++---- .../request/MendixNetworkInterceptor.kt | 13 ++- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/mendix/mendixnative/encryption/MendixEncryptionToolkit.kt b/android/src/main/java/com/mendix/mendixnative/encryption/MendixEncryptionToolkit.kt index 041f76e..7f2cba6 100644 --- a/android/src/main/java/com/mendix/mendixnative/encryption/MendixEncryptionToolkit.kt +++ b/android/src/main/java/com/mendix/mendixnative/encryption/MendixEncryptionToolkit.kt @@ -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 @@ -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 { @@ -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) } /** @@ -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 { - 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 ) @@ -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(), @@ -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) +} diff --git a/android/src/main/java/com/mendix/mendixnative/request/MendixNetworkInterceptor.kt b/android/src/main/java/com/mendix/mendixnative/request/MendixNetworkInterceptor.kt index a0b8cfb..6cc6361 100644 --- a/android/src/main/java/com/mendix/mendixnative/request/MendixNetworkInterceptor.kt +++ b/android/src/main/java/com/mendix/mendixnative/request/MendixNetworkInterceptor.kt @@ -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 @@ -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; }