Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 4 additions & 4 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ PODS:
- hermes-engine (0.14.1):
- hermes-engine/Pre-built (= 0.14.1)
- hermes-engine/Pre-built (0.14.1)
- MendixNative (0.3.2):
- MendixNative (0.4.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2832,8 +2832,8 @@ SPEC CHECKSUMS:
FBLazyVector: 82d1d7996af4c5850242966eb81e73f9a6dfab1e
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 9014a6bbc3a5b421aa3cb6e6dfa0606ad37c2694
MendixNative: 73ee938465675ee5ff94dfde36dd3e4217e94a2e
hermes-engine: f8a008831ed2a6655a05eec287332a01c1c2e108
MendixNative: 6797c879017115950e255901676934a272fb8102
op-sqlite: 64000c0da2357c4d73faf4d23ff34dd1ddb332d4
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
Expand Down Expand Up @@ -2913,4 +2913,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: fc108f79384f3de82954e66ee55dfd1e08b9f8c4

COCOAPODS: 1.16.2
COCOAPODS: 1.15.2
Loading