Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 1 addition & 3 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ jobs:
example/ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods
key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile.lock', 'yarn.lock', 'package.json', 'example/package.json') }}

- name: Install CocoaPods
working-directory: example
Expand Down
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
2 changes: 2 additions & 0 deletions example/ios/MendixNativeExample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
Expand Down Expand Up @@ -460,6 +461,7 @@
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
Expand Down
24 changes: 9 additions & 15 deletions example/ios/MendixNativeExample/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import MendixNative

@main
class AppDelegate: RCTAppDelegate {
class AppDelegate: ReactAppProvider {

override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

self.moduleName = "App"
self.dependencyProvider = RCTAppDependencyProvider()
self.initialProps = [:]
super.application(application, didFinishLaunchingWithOptions: launchOptions)

//Start - For MendixApplication compatibility only, not part of React Native template
SessionCookieStore.restore()
setUpProvider()

guard let bundleUrl = bundleURL() else {
let message = "No script URL provided. Make sure the metro packager is running or you have embedded a JS bundle in your application bundle."
NativeErrorHandler().handle(message: message, stackTrace: [])
return false
}

MxConfiguration.update(from:
ReactNative.shared.setup(
MendixApp.init(
identifier: nil,
bundleUrl: bundleUrl,
Expand All @@ -34,17 +27,18 @@ class AppDelegate: RCTAppDelegate {
splashScreenPresenter: nil,
reactLoading: nil,
enableThreeFingerGestures: false
)
),
launchOptions: launchOptions
)
//End - For MendixApplication compatibility only, not part of React Native template
return true
ReactNative.shared.start()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

override func applicationDidEnterBackground(_ application: UIApplication) {
func applicationDidEnterBackground(_ application: UIApplication) {
SessionCookieStore.persist()
}

override func applicationWillTerminate(_ application: UIApplication) {
func applicationWillTerminate(_ application: UIApplication) {
SessionCookieStore.persist()
}

Expand Down
3 changes: 3 additions & 0 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ if linkage != nil
use_frameworks! :linkage => linkage.to_sym
end

ENV['RCT_USE_RN_DEP'] = '1'
ENV['RCT_USE_PREBUILT_RNCORE'] = '1'

target 'MendixNativeExample' do
config = use_native_modules!

Expand Down
Loading
Loading