Skip to content

Commit dcbb53d

Browse files
Merge pull request #31 from mendix/moo/MOO-2230-enhance-cookie-encryption
[MOO-2230] Enhance Android cookie encryption
2 parents 28eab2b + 1f644e9 commit dcbb53d

8 files changed

Lines changed: 479 additions & 1139 deletions

File tree

.github/workflows/ios.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ jobs:
4141
example/ios/Pods
4242
~/Library/Caches/CocoaPods
4343
~/.cocoapods
44-
key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile.lock') }}
45-
restore-keys: |
46-
${{ runner.os }}-pods-
44+
key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile.lock', 'yarn.lock', 'package.json', 'example/package.json') }}
4745

4846
- name: Install CocoaPods
4947
working-directory: example

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- We strengthened Android cookie encryption by migrating from `AES/CBC/PKCS7Padding` to `AES/GCM/NoPadding`.
11+
1012
## [v0.4.0] - 2026-04-17
1113

1214
- We upgraded `@op-engineering/op-sqlite` from v15.0.7 to v15.2.5.

android/src/main/java/com/mendix/mendixnative/encryption/MendixEncryptionToolkit.kt

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ package com.mendix.mendixnative.encryption
33
import android.annotation.SuppressLint
44
import android.content.Context
55
import android.content.SharedPreferences
6-
import android.os.Build
76
import android.security.keystore.KeyGenParameterSpec
87
import android.security.keystore.KeyProperties
98
import android.util.Base64
109
import android.util.Base64.DEFAULT
11-
import androidx.annotation.RequiresApi
1210
import androidx.security.crypto.EncryptedSharedPreferences
1311
import androidx.security.crypto.MasterKey
1412
import java.io.IOException
@@ -17,10 +15,15 @@ import java.security.Key
1715
import java.security.KeyStore
1816
import javax.crypto.Cipher
1917
import javax.crypto.KeyGenerator
18+
import javax.crypto.spec.GCMParameterSpec
2019
import javax.crypto.spec.IvParameterSpec
2120

2221
private const val STORE_AES_KEY = "AES_KEY"
23-
private const val encryptionTransformationName = "AES/CBC/PKCS7Padding"
22+
private const val STORE_AES_KEY_V2 = "AES_KEY_V2"
23+
private const val legacyEncryptionTransformationName = "AES/CBC/PKCS7Padding"
24+
private const val modernEncryptionTransformationName = "AES/GCM/NoPadding"
25+
private const val modernEncryptionVersionPrefix = "v2:"
26+
private const val gcmTagLengthBits = 128
2427

2528
private var masterKey: MasterKey? = null
2629
fun getMasterKey(context: Context): MasterKey {
@@ -48,27 +51,41 @@ fun getEncryptedSharedPreferences(
4851
}
4952

5053
/**
51-
* generates or returns an application wide AES key.
54+
* returns an application wide AES key.
5255
*
5356
* @return Key
5457
*/
55-
@RequiresApi(Build.VERSION_CODES.M)
5658
private fun getAESKey(): Key? {
5759
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
58-
if (!keyStore.containsAlias(STORE_AES_KEY)) {
60+
return if (keyStore.containsAlias(STORE_AES_KEY))
61+
keyStore.getKey(STORE_AES_KEY, null)
62+
else null
63+
}
64+
65+
private fun getAESKeyV2(): Key? {
66+
return getOrCreateAESKey(
67+
STORE_AES_KEY_V2,
68+
KeyProperties.BLOCK_MODE_GCM,
69+
KeyProperties.ENCRYPTION_PADDING_NONE
70+
)
71+
}
72+
73+
private fun getOrCreateAESKey(alias: String, blockMode: String, encryptionPadding: String): Key? {
74+
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
75+
if (!keyStore.containsAlias(alias)) {
5976
val keyGenerator =
6077
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
6178
keyGenerator.init(
6279
KeyGenParameterSpec.Builder(
63-
STORE_AES_KEY,
80+
alias,
6481
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
6582
)
66-
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
67-
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7).build()
83+
.setBlockModes(blockMode)
84+
.setEncryptionPaddings(encryptionPadding).build()
6885
)
6986
keyGenerator.generateKey()
7087
}
71-
return keyStore.getKey(STORE_AES_KEY, null)
88+
return keyStore.getKey(alias, null)
7289
}
7390

7491
/**
@@ -79,13 +96,15 @@ private fun getAESKey(): Key? {
7996
*/
8097
fun encryptValue(
8198
value: String,
82-
@SuppressLint("NewApi", "LocalSuppress") getPassword: () -> Key? = { getAESKey() },
99+
@SuppressLint("NewApi", "LocalSuppress") getPassword: () -> Key? = { getAESKeyV2() },
83100
): Triple<ByteArray, ByteArray?, Boolean> {
84-
val cipher = Cipher.getInstance(encryptionTransformationName)
101+
val cipher = Cipher.getInstance(modernEncryptionTransformationName)
85102
cipher.init(Cipher.ENCRYPT_MODE, getPassword())
86103
val encryptedValue = cipher.doFinal(value.encodeToByteArray())
104+
val versionedEncryptedValue =
105+
"$modernEncryptionVersionPrefix${Base64.encodeToString(encryptedValue, DEFAULT)}"
87106
return Triple(
88-
Base64.encode(encryptedValue, DEFAULT),
107+
versionedEncryptedValue.encodeToByteArray(),
89108
Base64.encode(cipher.iv, DEFAULT),
90109
true
91110
)
@@ -101,9 +120,23 @@ fun encryptValue(
101120
fun decryptValue(
102121
value: String,
103122
iv: String?,
104-
@SuppressLint("NewApi", "LocalSuppress") getPassword: () -> Key? = { getAESKey() },
123+
@SuppressLint("NewApi", "LocalSuppress") legacyGetPassword: () -> Key? = { getAESKey() },
124+
@SuppressLint("NewApi", "LocalSuppress") modernGetPassword: () -> Key? = { getAESKeyV2() },
125+
): String {
126+
return if (value.startsWith(modernEncryptionVersionPrefix)) {
127+
decryptModernValue(value.removePrefix(modernEncryptionVersionPrefix), iv, modernGetPassword)
128+
} else {
129+
decryptLegacyValue(value, iv, legacyGetPassword)
130+
}
131+
}
132+
133+
private fun decryptLegacyValue(
134+
value: String,
135+
iv: String?,
136+
getPassword: () -> Key?,
105137
): String {
106-
val cipher = Cipher.getInstance(encryptionTransformationName)
138+
requireNotNull(iv) { "Missing IV for legacy encrypted value." }
139+
val cipher = Cipher.getInstance(legacyEncryptionTransformationName)
107140
cipher.init(
108141
Cipher.DECRYPT_MODE,
109142
getPassword(),
@@ -112,3 +145,19 @@ fun decryptValue(
112145
val unencryptedValue = cipher.doFinal(Base64.decode(value, DEFAULT))
113146
return String(unencryptedValue, Charsets.UTF_8)
114147
}
148+
149+
private fun decryptModernValue(
150+
value: String,
151+
iv: String?,
152+
getPassword: () -> Key?,
153+
): String {
154+
requireNotNull(iv) { "Missing nonce for modern encrypted value." }
155+
val cipher = Cipher.getInstance(modernEncryptionTransformationName)
156+
cipher.init(
157+
Cipher.DECRYPT_MODE,
158+
getPassword(),
159+
GCMParameterSpec(gcmTagLengthBits, Base64.decode(iv, DEFAULT))
160+
)
161+
val unencryptedValue = cipher.doFinal(Base64.decode(value, DEFAULT))
162+
return String(unencryptedValue, Charsets.UTF_8)
163+
}

android/src/main/java/com/mendix/mendixnative/request/MendixNetworkInterceptor.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.mendix.mendixnative.request
22

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

4142
if (encryptedCookieExists!! && key.startsWith(encryptedCookieKeyPrefix)) {
42-
val params = cookieValueToDecryptionParams(value)
43-
val decryptedValue = decryptValue(params.first, params.second)
44-
45-
return@map "${key.removePrefix(encryptedCookieKeyPrefix)}=$decryptedValue"
43+
try {
44+
val params = cookieValueToDecryptionParams(value)
45+
val decryptedValue = decryptValue(params.first, params.second)
46+
return@map "${key.removePrefix(encryptedCookieKeyPrefix)}=$decryptedValue"
47+
} catch (e: Exception) {
48+
Log.w("MendixNetworkInterceptor", "Failed to decrypt cookie $key, dropping it", e)
49+
return@map null
50+
}
4651
} else if (!encryptedCookieExists) {
4752
return@map it;
4853
}

example/ios/MendixNativeExample.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@
388388
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
389389
SDKROOT = iphoneos;
390390
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
391+
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
391392
USE_HERMES = true;
392393
};
393394
name = Debug;
@@ -460,6 +461,7 @@
460461
);
461462
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
462463
SDKROOT = iphoneos;
464+
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
463465
USE_HERMES = true;
464466
VALIDATE_PRODUCT = YES;
465467
};

example/ios/MendixNativeExample/AppDelegate.swift

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,22 @@
11
import UIKit
22
import React
3-
import React_RCTAppDelegate
4-
import ReactAppDependencyProvider
53
import MendixNative
64

75
@main
8-
class AppDelegate: RCTAppDelegate {
6+
class AppDelegate: ReactAppProvider {
97

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

12-
self.moduleName = "App"
13-
self.dependencyProvider = RCTAppDependencyProvider()
14-
self.initialProps = [:]
15-
super.application(application, didFinishLaunchingWithOptions: launchOptions)
16-
17-
//Start - For MendixApplication compatibility only, not part of React Native template
1810
SessionCookieStore.restore()
11+
setUpProvider()
1912

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

26-
MxConfiguration.update(from:
19+
ReactNative.shared.setup(
2720
MendixApp.init(
2821
identifier: nil,
2922
bundleUrl: bundleUrl,
@@ -34,17 +27,18 @@ class AppDelegate: RCTAppDelegate {
3427
splashScreenPresenter: nil,
3528
reactLoading: nil,
3629
enableThreeFingerGestures: false
37-
)
30+
),
31+
launchOptions: launchOptions
3832
)
39-
//End - For MendixApplication compatibility only, not part of React Native template
40-
return true
33+
ReactNative.shared.start()
34+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
4135
}
4236

43-
override func applicationDidEnterBackground(_ application: UIApplication) {
37+
func applicationDidEnterBackground(_ application: UIApplication) {
4438
SessionCookieStore.persist()
4539
}
4640

47-
override func applicationWillTerminate(_ application: UIApplication) {
41+
func applicationWillTerminate(_ application: UIApplication) {
4842
SessionCookieStore.persist()
4943
}
5044

example/ios/Podfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ if linkage != nil
1616
use_frameworks! :linkage => linkage.to_sym
1717
end
1818

19+
ENV['RCT_USE_RN_DEP'] = '1'
20+
ENV['RCT_USE_PREBUILT_RNCORE'] = '1'
21+
1922
target 'MendixNativeExample' do
2023
config = use_native_modules!
2124

0 commit comments

Comments
 (0)