From 20e895a8d1f8ffe629141716595080caadee317d Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 16:21:30 +0100 Subject: [PATCH 01/13] [MOB-9235] Cleanup --- .../src/main/java/com/iterable/iterableapi/IterableApi.java | 2 +- .../main/java/com/iterable/iterableapi/IterableKeychain.kt | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 49cfd3c6a..140f3eaec 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -49,7 +49,7 @@ public class IterableApi { private String inboxSessionId; private IterableAuthManager authManager; private HashMap deviceAttributes = new HashMap<>(); - private IterableKeychain keychain; + public IterableKeychain keychain; void fetchRemoteConfiguration() { apiClient.getRemoteConfiguration(new IterableHelper.IterableActionHandler() { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt index 80f2aff74..382b27d58 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt @@ -39,13 +39,12 @@ class IterableKeychain { } } dataMigrator.attemptMigration() - } + IterableLogger.v(TAG, "Migration completed") + } } catch (e: Exception) { IterableLogger.w(TAG, "Migration failed, clearing data", e) handleDecryptionError(e) } - - IterableLogger.v(TAG, "Migration completed") } private fun handleDecryptionError(e: Exception? = null) { From cb512632c3836a4805b06ace6116938c26f5c74b Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 16:23:41 +0100 Subject: [PATCH 02/13] [MOB-9235] Cleanup --- .../src/main/java/com/iterable/iterableapi/IterableApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 140f3eaec..49cfd3c6a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -49,7 +49,7 @@ public class IterableApi { private String inboxSessionId; private IterableAuthManager authManager; private HashMap deviceAttributes = new HashMap<>(); - public IterableKeychain keychain; + private IterableKeychain keychain; void fetchRemoteConfiguration() { apiClient.getRemoteConfiguration(new IterableHelper.IterableActionHandler() { From 74358a306cb137b162ebb1af0925f554394f6124 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 16:50:03 +0100 Subject: [PATCH 03/13] [MOB-9235] Make encryptor support old versions --- .../iterableapi/IterableDataEncryptor.kt | 159 ++++++++++++------ .../iterableapi/IterableKeychainTest.kt | 71 ++++++++ 2 files changed, 179 insertions(+), 51 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt index 1597639cf..970ad2089 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt @@ -11,18 +11,21 @@ import javax.crypto.spec.GCMParameterSpec import android.os.Build import java.security.KeyStore.PasswordProtection import androidx.annotation.VisibleForTesting +import java.security.SecureRandom +import javax.crypto.spec.IvParameterSpec +import android.annotation.TargetApi class IterableDataEncryptor { companion object { private const val TAG = "IterableDataEncryptor" private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TRANSFORMATION_MODERN = "AES/GCM/NoPadding" + private const val TRANSFORMATION_LEGACY = "AES/CBC/PKCS5Padding" private const val ITERABLE_KEY_ALIAS = "iterable_encryption_key" - private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 128 + private const val IV_LENGTH = 16 private val TEST_KEYSTORE_PASSWORD = "test_password".toCharArray() - // Make keyStore static so it's shared across instances private val keyStore: KeyStore by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { try { @@ -62,28 +65,33 @@ class IterableDataEncryptor { } private fun canUseAndroidKeyStore(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && keyStore.type == ANDROID_KEYSTORE } + @TargetApi(Build.VERSION_CODES.M) private fun generateAndroidKeyStoreKey(): Unit? { return try { - val keyGenerator = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, - ANDROID_KEYSTORE - ) - - val keySpec = KeyGenParameterSpec.Builder( - ITERABLE_KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - - keyGenerator.init(keySpec) - keyGenerator.generateKey() - Unit + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + val keySpec = KeyGenParameterSpec.Builder( + ITERABLE_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM, KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE, KeyProperties.ENCRYPTION_PADDING_PKCS7) + .build() + + keyGenerator.init(keySpec) + keyGenerator.generateKey() + Unit + } else { + null + } } catch (e: Exception) { IterableLogger.e(TAG, "Failed to generate key using AndroidKeyStore", e) null @@ -92,7 +100,7 @@ class IterableDataEncryptor { private fun generateFallbackKey() { val keyGenerator = KeyGenerator.getInstance("AES") - keyGenerator.init(256) // 256-bit AES key + keyGenerator.init(256) val secretKey = keyGenerator.generateKey() val keyEntry = KeyStore.SecretKeyEntry(secretKey) @@ -113,31 +121,22 @@ class IterableDataEncryptor { return (keyStore.getEntry(ITERABLE_KEY_ALIAS, protParam) as KeyStore.SecretKeyEntry).secretKey } - class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) - - fun resetKeys() { - try { - keyStore.deleteEntry(ITERABLE_KEY_ALIAS) - generateKey() - } catch (e: Exception) { - IterableLogger.e(TAG, "Failed to regenerate key", e) - } - } - fun encrypt(value: String?): String? { if (value == null) return null try { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, getKey()) - - val iv = cipher.iv - val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val data = value.toByteArray(Charsets.UTF_8) + val encryptedData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + encryptModern(data) + } else { + encryptLegacy(data) + } - // Combine IV and encrypted data - val combined = ByteArray(iv.size + encrypted.size) - System.arraycopy(iv, 0, combined, 0, iv.size) - System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size) + // Combine isModern flag, IV, and encrypted data + val combined = ByteArray(1 + encryptedData.iv.size + encryptedData.data.size) + combined[0] = if (encryptedData.isModernEncryption) 1 else 0 + System.arraycopy(encryptedData.iv, 0, combined, 1, encryptedData.iv.size) + System.arraycopy(encryptedData.data, 0, combined, 1 + encryptedData.iv.size, encryptedData.data.size) return Base64.encodeToString(combined, Base64.NO_WRAP) } catch (e: Exception) { @@ -151,23 +150,81 @@ class IterableDataEncryptor { try { val combined = Base64.decode(value, Base64.NO_WRAP) + + // Extract components + val isModern = combined[0] == 1.toByte() + val iv = combined.copyOfRange(1, 1 + IV_LENGTH) + val encrypted = combined.copyOfRange(1 + IV_LENGTH, combined.size) + + val encryptedData = EncryptedData(encrypted, iv, isModern) + val decrypted = if (isModern && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + decryptModern(encryptedData) + } else { + decryptLegacy(encryptedData) + } - // Extract IV - val iv = combined.copyOfRange(0, GCM_IV_LENGTH) - val encrypted = combined.copyOfRange(GCM_IV_LENGTH, combined.size) - - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) - cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) - - return String(cipher.doFinal(encrypted), Charsets.UTF_8) + return String(decrypted, Charsets.UTF_8) } catch (e: Exception) { IterableLogger.e(TAG, "Decryption failed", e) throw DecryptionException("Failed to decrypt data", e) } } - // Add this method for testing purposes + private fun encryptModern(data: ByteArray): EncryptedData { + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) + val iv = generateIV() + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec) + val encrypted = cipher.doFinal(data) + return EncryptedData(encrypted, iv, true) + } + + private fun encryptLegacy(data: ByteArray): EncryptedData { + val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY) + val iv = generateIV() + val spec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec) + val encrypted = cipher.doFinal(data) + return EncryptedData(encrypted, iv, false) + } + + private fun decryptModern(encryptedData: EncryptedData): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + return cipher.doFinal(encryptedData.data) + } + + private fun decryptLegacy(encryptedData: EncryptedData): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY) + val spec = IvParameterSpec(encryptedData.iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + return cipher.doFinal(encryptedData.data) + } + + private fun generateIV(): ByteArray { + val iv = ByteArray(IV_LENGTH) + SecureRandom().nextBytes(iv) + return iv + } + + data class EncryptedData( + val data: ByteArray, + val iv: ByteArray, + val isModernEncryption: Boolean + ) + + class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) + + fun resetKeys() { + try { + keyStore.deleteEntry(ITERABLE_KEY_ALIAS) + generateKey() + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to regenerate key", e) + } + } + @VisibleForTesting fun getKeyStore(): KeyStore = keyStore } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 14bc49e9e..1618d88f8 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import android.util.Base64 +import android.os.Build +import java.lang.reflect.Field +import java.lang.reflect.Modifier import org.junit.Before import org.junit.After import org.junit.Test @@ -285,4 +288,72 @@ class IterableKeychainTest { // Verify attemptMigration was called exactly once verify(mockMigrator, times(1)).attemptMigration() } + + @Test + fun testModernEncryption() { + // Mock API level 23 (modern encryption) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.M) + + val testData = "test_modern_data" + val encryptor = IterableDataEncryptor() + + val encrypted = encryptor.encrypt(testData) + assertNotNull(encrypted) + + val decrypted = encryptor.decrypt(encrypted) + assertEquals(testData, decrypted) + + // Verify first byte indicates modern encryption + val bytes = Base64.decode(encrypted, Base64.NO_WRAP) + assertEquals(1.toByte(), bytes[0]) + } + + @Test + fun testLegacyEncryption() { + // Mock API level 16 (legacy encryption) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) + + val testData = "test_legacy_data" + val encryptor = IterableDataEncryptor() + + val encrypted = encryptor.encrypt(testData) + assertNotNull(encrypted) + + val decrypted = encryptor.decrypt(encrypted) + assertEquals(testData, decrypted) + + // Verify first byte indicates legacy encryption + val bytes = Base64.decode(encrypted, Base64.NO_WRAP) + assertEquals(0.toByte(), bytes[0]) + } + + @Test + fun testCrossVersionCompatibility() { + val testData = "test_cross_version_data" + + // First encrypt with legacy encryption (API 16) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) + val encryptor = IterableDataEncryptor() + val encryptedLegacy = encryptor.encrypt(testData) + + // Then decrypt with modern version (API 23) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.M) + val decryptedOnModern = encryptor.decrypt(encryptedLegacy) + assertEquals(testData, decryptedOnModern) + + // Now encrypt with modern and decrypt with legacy + val encryptedModern = encryptor.encrypt(testData) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) + val decryptedOnLegacy = encryptor.decrypt(encryptedModern) + assertEquals(testData, decryptedOnLegacy) + } + + // Helper method to set final static fields for testing + private fun setFinalStatic(field: Field, newValue: Any) { + field.isAccessible = true + val modifiersField = Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + field.set(null, newValue) + } } \ No newline at end of file From 3519961c764c8fd1441cd2931f6ba699697f174c Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 16:58:49 +0100 Subject: [PATCH 04/13] [MOB-9235] Make encryptor support old versions --- .../iterableapi/IterableDataEncryptor.kt | 22 +++++- .../IterableDataEncryptorTest.java | 79 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt index 970ad2089..2e4e41a40 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt @@ -157,20 +157,35 @@ class IterableDataEncryptor { val encrypted = combined.copyOfRange(1 + IV_LENGTH, combined.size) val encryptedData = EncryptedData(encrypted, iv, isModern) - val decrypted = if (isModern && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + + // If it's modern encryption and we're on an old device, fail fast + if (isModern && Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + throw DecryptionException("Modern encryption cannot be decrypted on legacy devices") + } + + // Use the appropriate decryption method + val decrypted = if (isModern) { decryptModern(encryptedData) } else { decryptLegacy(encryptedData) } return String(decrypted, Charsets.UTF_8) + } catch (e: DecryptionException) { + // Re-throw DecryptionException directly + throw e } catch (e: Exception) { IterableLogger.e(TAG, "Decryption failed", e) throw DecryptionException("Failed to decrypt data", e) } } + @TargetApi(Build.VERSION_CODES.KITKAT) private fun encryptModern(data: ByteArray): EncryptedData { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return encryptLegacy(data) + } + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) val iv = generateIV() val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) @@ -188,7 +203,12 @@ class IterableDataEncryptor { return EncryptedData(encrypted, iv, false) } + @TargetApi(Build.VERSION_CODES.KITKAT) private fun decryptModern(encryptedData: EncryptedData): ByteArray { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + throw DecryptionException("Cannot decrypt modern encryption on legacy device") + } + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv) cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index 896c28fdc..b41cb2bfa 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -1,6 +1,8 @@ package com.iterable.iterableapi; import android.content.SharedPreferences; +import android.os.Build; +import android.util.Base64; import org.junit.Before; import org.junit.Test; @@ -15,6 +17,8 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.fail; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.security.KeyStore; import java.util.ArrayList; import java.util.List; @@ -296,4 +300,79 @@ public void testDecryptionAfterKeyLoss() { assertEquals("Exception should have correct message", "Failed to decrypt data", e.getMessage()); } } + + @Test + public void testEncryptionAcrossApiLevels() { + String testData = "test data for cross-version compatibility"; + + // Test API 16 (Legacy) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + String encryptedOnApi16 = encryptor.encrypt(testData); + + // Test API 18 (Legacy) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2); + String encryptedOnApi18 = encryptor.encrypt(testData); + assertEquals("Legacy decryption should work on API 18", testData, encryptor.decrypt(encryptedOnApi16)); + + // Test API 19 (Modern - First version with GCM support) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); + String encryptedOnApi19 = encryptor.encrypt(testData); + assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi16)); + assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi18)); + + // Test API 23 (Modern with KeyStore) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M); + String encryptedOnApi23 = encryptor.encrypt(testData); + assertEquals("Should decrypt legacy data on API 23", testData, encryptor.decrypt(encryptedOnApi16)); + assertEquals("Should decrypt API 19 data on API 23", testData, encryptor.decrypt(encryptedOnApi19)); + + // Test that modern encryption fails on legacy devices + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + try { + encryptor.decrypt(encryptedOnApi19); + fail("Should not be able to decrypt modern encryption on legacy device"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertEquals("Should have correct error message", "Modern encryption cannot be decrypted on legacy devices", e.getMessage()); + } + try { + encryptor.decrypt(encryptedOnApi23); + fail("Should not be able to decrypt modern encryption on legacy device"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertEquals("Should have correct error message", "Modern encryption cannot be decrypted on legacy devices", e.getMessage()); + } + } + + @Test + public void testEncryptionMethodFlag() { + String testData = "test data for encryption method verification"; + + // Test legacy encryption flag (API 16) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + String legacyEncrypted = encryptor.encrypt(testData); + byte[] legacyBytes = Base64.decode(legacyEncrypted, Base64.NO_WRAP); + assertEquals("Legacy encryption should have flag 0", 0, legacyBytes[0]); + + // Test modern encryption flag (API 19) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); + String modernEncrypted = encryptor.encrypt(testData); + byte[] modernBytes = Base64.decode(modernEncrypted, Base64.NO_WRAP); + assertEquals("Modern encryption should have flag 1", 1, modernBytes[0]); + } + + private static void setFinalStatic(Class clazz, String fieldName, Object newValue) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + + field.set(null, newValue); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } \ No newline at end of file From cc696095aa18ceaa800eea72ae995f97a0705929 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 16:50:03 +0100 Subject: [PATCH 05/13] [MOB-9235] Make encryptor support old versions --- .../iterableapi/IterableDataEncryptor.kt | 159 ++++++++++++------ .../iterableapi/IterableKeychainTest.kt | 71 ++++++++ 2 files changed, 179 insertions(+), 51 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt index 1597639cf..970ad2089 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt @@ -11,18 +11,21 @@ import javax.crypto.spec.GCMParameterSpec import android.os.Build import java.security.KeyStore.PasswordProtection import androidx.annotation.VisibleForTesting +import java.security.SecureRandom +import javax.crypto.spec.IvParameterSpec +import android.annotation.TargetApi class IterableDataEncryptor { companion object { private const val TAG = "IterableDataEncryptor" private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TRANSFORMATION_MODERN = "AES/GCM/NoPadding" + private const val TRANSFORMATION_LEGACY = "AES/CBC/PKCS5Padding" private const val ITERABLE_KEY_ALIAS = "iterable_encryption_key" - private const val GCM_IV_LENGTH = 12 private const val GCM_TAG_LENGTH = 128 + private const val IV_LENGTH = 16 private val TEST_KEYSTORE_PASSWORD = "test_password".toCharArray() - // Make keyStore static so it's shared across instances private val keyStore: KeyStore by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { try { @@ -62,28 +65,33 @@ class IterableDataEncryptor { } private fun canUseAndroidKeyStore(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && keyStore.type == ANDROID_KEYSTORE } + @TargetApi(Build.VERSION_CODES.M) private fun generateAndroidKeyStoreKey(): Unit? { return try { - val keyGenerator = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, - ANDROID_KEYSTORE - ) - - val keySpec = KeyGenParameterSpec.Builder( - ITERABLE_KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - - keyGenerator.init(keySpec) - keyGenerator.generateKey() - Unit + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + val keySpec = KeyGenParameterSpec.Builder( + ITERABLE_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM, KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE, KeyProperties.ENCRYPTION_PADDING_PKCS7) + .build() + + keyGenerator.init(keySpec) + keyGenerator.generateKey() + Unit + } else { + null + } } catch (e: Exception) { IterableLogger.e(TAG, "Failed to generate key using AndroidKeyStore", e) null @@ -92,7 +100,7 @@ class IterableDataEncryptor { private fun generateFallbackKey() { val keyGenerator = KeyGenerator.getInstance("AES") - keyGenerator.init(256) // 256-bit AES key + keyGenerator.init(256) val secretKey = keyGenerator.generateKey() val keyEntry = KeyStore.SecretKeyEntry(secretKey) @@ -113,31 +121,22 @@ class IterableDataEncryptor { return (keyStore.getEntry(ITERABLE_KEY_ALIAS, protParam) as KeyStore.SecretKeyEntry).secretKey } - class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) - - fun resetKeys() { - try { - keyStore.deleteEntry(ITERABLE_KEY_ALIAS) - generateKey() - } catch (e: Exception) { - IterableLogger.e(TAG, "Failed to regenerate key", e) - } - } - fun encrypt(value: String?): String? { if (value == null) return null try { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, getKey()) - - val iv = cipher.iv - val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val data = value.toByteArray(Charsets.UTF_8) + val encryptedData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + encryptModern(data) + } else { + encryptLegacy(data) + } - // Combine IV and encrypted data - val combined = ByteArray(iv.size + encrypted.size) - System.arraycopy(iv, 0, combined, 0, iv.size) - System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size) + // Combine isModern flag, IV, and encrypted data + val combined = ByteArray(1 + encryptedData.iv.size + encryptedData.data.size) + combined[0] = if (encryptedData.isModernEncryption) 1 else 0 + System.arraycopy(encryptedData.iv, 0, combined, 1, encryptedData.iv.size) + System.arraycopy(encryptedData.data, 0, combined, 1 + encryptedData.iv.size, encryptedData.data.size) return Base64.encodeToString(combined, Base64.NO_WRAP) } catch (e: Exception) { @@ -151,23 +150,81 @@ class IterableDataEncryptor { try { val combined = Base64.decode(value, Base64.NO_WRAP) + + // Extract components + val isModern = combined[0] == 1.toByte() + val iv = combined.copyOfRange(1, 1 + IV_LENGTH) + val encrypted = combined.copyOfRange(1 + IV_LENGTH, combined.size) + + val encryptedData = EncryptedData(encrypted, iv, isModern) + val decrypted = if (isModern && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + decryptModern(encryptedData) + } else { + decryptLegacy(encryptedData) + } - // Extract IV - val iv = combined.copyOfRange(0, GCM_IV_LENGTH) - val encrypted = combined.copyOfRange(GCM_IV_LENGTH, combined.size) - - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) - cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) - - return String(cipher.doFinal(encrypted), Charsets.UTF_8) + return String(decrypted, Charsets.UTF_8) } catch (e: Exception) { IterableLogger.e(TAG, "Decryption failed", e) throw DecryptionException("Failed to decrypt data", e) } } - // Add this method for testing purposes + private fun encryptModern(data: ByteArray): EncryptedData { + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) + val iv = generateIV() + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec) + val encrypted = cipher.doFinal(data) + return EncryptedData(encrypted, iv, true) + } + + private fun encryptLegacy(data: ByteArray): EncryptedData { + val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY) + val iv = generateIV() + val spec = IvParameterSpec(iv) + cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec) + val encrypted = cipher.doFinal(data) + return EncryptedData(encrypted, iv, false) + } + + private fun decryptModern(encryptedData: EncryptedData): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + return cipher.doFinal(encryptedData.data) + } + + private fun decryptLegacy(encryptedData: EncryptedData): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY) + val spec = IvParameterSpec(encryptedData.iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + return cipher.doFinal(encryptedData.data) + } + + private fun generateIV(): ByteArray { + val iv = ByteArray(IV_LENGTH) + SecureRandom().nextBytes(iv) + return iv + } + + data class EncryptedData( + val data: ByteArray, + val iv: ByteArray, + val isModernEncryption: Boolean + ) + + class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) + + fun resetKeys() { + try { + keyStore.deleteEntry(ITERABLE_KEY_ALIAS) + generateKey() + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to regenerate key", e) + } + } + @VisibleForTesting fun getKeyStore(): KeyStore = keyStore } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 14bc49e9e..1618d88f8 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import android.util.Base64 +import android.os.Build +import java.lang.reflect.Field +import java.lang.reflect.Modifier import org.junit.Before import org.junit.After import org.junit.Test @@ -285,4 +288,72 @@ class IterableKeychainTest { // Verify attemptMigration was called exactly once verify(mockMigrator, times(1)).attemptMigration() } + + @Test + fun testModernEncryption() { + // Mock API level 23 (modern encryption) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.M) + + val testData = "test_modern_data" + val encryptor = IterableDataEncryptor() + + val encrypted = encryptor.encrypt(testData) + assertNotNull(encrypted) + + val decrypted = encryptor.decrypt(encrypted) + assertEquals(testData, decrypted) + + // Verify first byte indicates modern encryption + val bytes = Base64.decode(encrypted, Base64.NO_WRAP) + assertEquals(1.toByte(), bytes[0]) + } + + @Test + fun testLegacyEncryption() { + // Mock API level 16 (legacy encryption) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) + + val testData = "test_legacy_data" + val encryptor = IterableDataEncryptor() + + val encrypted = encryptor.encrypt(testData) + assertNotNull(encrypted) + + val decrypted = encryptor.decrypt(encrypted) + assertEquals(testData, decrypted) + + // Verify first byte indicates legacy encryption + val bytes = Base64.decode(encrypted, Base64.NO_WRAP) + assertEquals(0.toByte(), bytes[0]) + } + + @Test + fun testCrossVersionCompatibility() { + val testData = "test_cross_version_data" + + // First encrypt with legacy encryption (API 16) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) + val encryptor = IterableDataEncryptor() + val encryptedLegacy = encryptor.encrypt(testData) + + // Then decrypt with modern version (API 23) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.M) + val decryptedOnModern = encryptor.decrypt(encryptedLegacy) + assertEquals(testData, decryptedOnModern) + + // Now encrypt with modern and decrypt with legacy + val encryptedModern = encryptor.encrypt(testData) + setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) + val decryptedOnLegacy = encryptor.decrypt(encryptedModern) + assertEquals(testData, decryptedOnLegacy) + } + + // Helper method to set final static fields for testing + private fun setFinalStatic(field: Field, newValue: Any) { + field.isAccessible = true + val modifiersField = Field::class.java.getDeclaredField("modifiers") + modifiersField.isAccessible = true + modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) + field.set(null, newValue) + } } \ No newline at end of file From 5e31e37874bf7084a4a1bb112979321d1d46ca7d Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 16:58:49 +0100 Subject: [PATCH 06/13] [MOB-9235] Make encryptor support old versions --- .../iterableapi/IterableDataEncryptor.kt | 22 +++++- .../IterableDataEncryptorTest.java | 79 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt index 970ad2089..2e4e41a40 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt @@ -157,20 +157,35 @@ class IterableDataEncryptor { val encrypted = combined.copyOfRange(1 + IV_LENGTH, combined.size) val encryptedData = EncryptedData(encrypted, iv, isModern) - val decrypted = if (isModern && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + + // If it's modern encryption and we're on an old device, fail fast + if (isModern && Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + throw DecryptionException("Modern encryption cannot be decrypted on legacy devices") + } + + // Use the appropriate decryption method + val decrypted = if (isModern) { decryptModern(encryptedData) } else { decryptLegacy(encryptedData) } return String(decrypted, Charsets.UTF_8) + } catch (e: DecryptionException) { + // Re-throw DecryptionException directly + throw e } catch (e: Exception) { IterableLogger.e(TAG, "Decryption failed", e) throw DecryptionException("Failed to decrypt data", e) } } + @TargetApi(Build.VERSION_CODES.KITKAT) private fun encryptModern(data: ByteArray): EncryptedData { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return encryptLegacy(data) + } + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) val iv = generateIV() val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) @@ -188,7 +203,12 @@ class IterableDataEncryptor { return EncryptedData(encrypted, iv, false) } + @TargetApi(Build.VERSION_CODES.KITKAT) private fun decryptModern(encryptedData: EncryptedData): ByteArray { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + throw DecryptionException("Cannot decrypt modern encryption on legacy device") + } + val cipher = Cipher.getInstance(TRANSFORMATION_MODERN) val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv) cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index 896c28fdc..b41cb2bfa 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -1,6 +1,8 @@ package com.iterable.iterableapi; import android.content.SharedPreferences; +import android.os.Build; +import android.util.Base64; import org.junit.Before; import org.junit.Test; @@ -15,6 +17,8 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.fail; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.security.KeyStore; import java.util.ArrayList; import java.util.List; @@ -296,4 +300,79 @@ public void testDecryptionAfterKeyLoss() { assertEquals("Exception should have correct message", "Failed to decrypt data", e.getMessage()); } } + + @Test + public void testEncryptionAcrossApiLevels() { + String testData = "test data for cross-version compatibility"; + + // Test API 16 (Legacy) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + String encryptedOnApi16 = encryptor.encrypt(testData); + + // Test API 18 (Legacy) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2); + String encryptedOnApi18 = encryptor.encrypt(testData); + assertEquals("Legacy decryption should work on API 18", testData, encryptor.decrypt(encryptedOnApi16)); + + // Test API 19 (Modern - First version with GCM support) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); + String encryptedOnApi19 = encryptor.encrypt(testData); + assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi16)); + assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi18)); + + // Test API 23 (Modern with KeyStore) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M); + String encryptedOnApi23 = encryptor.encrypt(testData); + assertEquals("Should decrypt legacy data on API 23", testData, encryptor.decrypt(encryptedOnApi16)); + assertEquals("Should decrypt API 19 data on API 23", testData, encryptor.decrypt(encryptedOnApi19)); + + // Test that modern encryption fails on legacy devices + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + try { + encryptor.decrypt(encryptedOnApi19); + fail("Should not be able to decrypt modern encryption on legacy device"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertEquals("Should have correct error message", "Modern encryption cannot be decrypted on legacy devices", e.getMessage()); + } + try { + encryptor.decrypt(encryptedOnApi23); + fail("Should not be able to decrypt modern encryption on legacy device"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertEquals("Should have correct error message", "Modern encryption cannot be decrypted on legacy devices", e.getMessage()); + } + } + + @Test + public void testEncryptionMethodFlag() { + String testData = "test data for encryption method verification"; + + // Test legacy encryption flag (API 16) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + String legacyEncrypted = encryptor.encrypt(testData); + byte[] legacyBytes = Base64.decode(legacyEncrypted, Base64.NO_WRAP); + assertEquals("Legacy encryption should have flag 0", 0, legacyBytes[0]); + + // Test modern encryption flag (API 19) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); + String modernEncrypted = encryptor.encrypt(testData); + byte[] modernBytes = Base64.decode(modernEncrypted, Base64.NO_WRAP); + assertEquals("Modern encryption should have flag 1", 1, modernBytes[0]); + } + + private static void setFinalStatic(Class clazz, String fieldName, Object newValue) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + + field.set(null, newValue); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } \ No newline at end of file From 22191d4948c8a9439cf7f79b0c4318f33af83fab Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:02:25 +0100 Subject: [PATCH 07/13] [MOB-9235] Make encryptor support old versions --- .../IterableDataEncryptorTest.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index b41cb2bfa..328fbcf62 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -361,6 +361,66 @@ public void testEncryptionMethodFlag() { assertEquals("Modern encryption should have flag 1", 1, modernBytes[0]); } + @Test + public void testDecryptCorruptData() { + String testData = "test data"; + String encrypted = encryptor.encrypt(testData); + byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); + + // Corrupt the data portion + bytes[bytes.length - 1] ^= 0xFF; + String corrupted = Base64.encodeToString(bytes, Base64.NO_WRAP); + + try { + encryptor.decrypt(corrupted); + fail("Should throw exception for corrupted data"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertNotNull("Should have a cause", e.getCause()); + } + } + + @Test + public void testDecryptManipulatedIV() { + String testData = "test data"; + String encrypted = encryptor.encrypt(testData); + byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); + + // Manipulate the IV + bytes[1] ^= 0xFF; // First byte after version flag + String manipulated = Base64.encodeToString(bytes, Base64.NO_WRAP); + + try { + encryptor.decrypt(manipulated); + fail("Should throw exception for manipulated IV"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertNotNull("Should have a cause", e.getCause()); + } + } + + @Test + public void testDecryptManipulatedVersionFlag() { + // Test on API 16 device + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + + String testData = "test data"; + String encrypted = encryptor.encrypt(testData); + byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); + + // Change version flag from legacy (0) to modern (1) + bytes[0] = 1; + String manipulated = Base64.encodeToString(bytes, Base64.NO_WRAP); + + try { + encryptor.decrypt(manipulated); + fail("Should throw exception for manipulated version flag"); + } catch (Exception e) { + assertTrue("Should be DecryptionException", e instanceof IterableDataEncryptor.DecryptionException); + assertEquals("Modern encryption cannot be decrypted on legacy devices", e.getMessage()); + } + } + private static void setFinalStatic(Class clazz, String fieldName, Object newValue) { try { Field field = clazz.getDeclaredField(fieldName); From d40f3549b3b6a981618c5cfbe1e6c424b49d30e4 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:04:37 +0100 Subject: [PATCH 08/13] [MOB-9235] Make encryptor support old versions --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 856867044..3dc8e817b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,9 @@ jobs: - run: touch local.properties + - name: Lint Check + run: ./gradlew :iterableapi:lintDebug + - name: Checkstyle run: ./gradlew :iterableapi:checkstyle :iterableapi-ui:assembleDebug From 6273cad6a00a23e6e5ce0d81c4dae8be61695181 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:07:30 +0100 Subject: [PATCH 09/13] [MOB-9235] Make encryptor support old versions --- .../iterableapi/IterableKeychainTest.kt | 66 ------------------- 1 file changed, 66 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 1618d88f8..76be9436e 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -289,71 +289,5 @@ class IterableKeychainTest { verify(mockMigrator, times(1)).attemptMigration() } - @Test - fun testModernEncryption() { - // Mock API level 23 (modern encryption) - setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.M) - - val testData = "test_modern_data" - val encryptor = IterableDataEncryptor() - - val encrypted = encryptor.encrypt(testData) - assertNotNull(encrypted) - - val decrypted = encryptor.decrypt(encrypted) - assertEquals(testData, decrypted) - - // Verify first byte indicates modern encryption - val bytes = Base64.decode(encrypted, Base64.NO_WRAP) - assertEquals(1.toByte(), bytes[0]) - } - @Test - fun testLegacyEncryption() { - // Mock API level 16 (legacy encryption) - setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) - - val testData = "test_legacy_data" - val encryptor = IterableDataEncryptor() - - val encrypted = encryptor.encrypt(testData) - assertNotNull(encrypted) - - val decrypted = encryptor.decrypt(encrypted) - assertEquals(testData, decrypted) - - // Verify first byte indicates legacy encryption - val bytes = Base64.decode(encrypted, Base64.NO_WRAP) - assertEquals(0.toByte(), bytes[0]) - } - - @Test - fun testCrossVersionCompatibility() { - val testData = "test_cross_version_data" - - // First encrypt with legacy encryption (API 16) - setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) - val encryptor = IterableDataEncryptor() - val encryptedLegacy = encryptor.encrypt(testData) - - // Then decrypt with modern version (API 23) - setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.M) - val decryptedOnModern = encryptor.decrypt(encryptedLegacy) - assertEquals(testData, decryptedOnModern) - - // Now encrypt with modern and decrypt with legacy - val encryptedModern = encryptor.encrypt(testData) - setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), Build.VERSION_CODES.JELLY_BEAN) - val decryptedOnLegacy = encryptor.decrypt(encryptedModern) - assertEquals(testData, decryptedOnLegacy) - } - - // Helper method to set final static fields for testing - private fun setFinalStatic(field: Field, newValue: Any) { - field.isAccessible = true - val modifiersField = Field::class.java.getDeclaredField("modifiers") - modifiersField.isAccessible = true - modifiersField.setInt(field, field.modifiers and Modifier.FINAL.inv()) - field.set(null, newValue) - } } \ No newline at end of file From 2e8f0b33da19a7ec31ddbcc3e90bc447a36cfb93 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:08:23 +0100 Subject: [PATCH 10/13] [MOB-9235] Make encryptor support old versions --- .../java/com/iterable/iterableapi/IterableKeychainTest.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 76be9436e..14bc49e9e 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -4,9 +4,6 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import android.util.Base64 -import android.os.Build -import java.lang.reflect.Field -import java.lang.reflect.Modifier import org.junit.Before import org.junit.After import org.junit.Test @@ -288,6 +285,4 @@ class IterableKeychainTest { // Verify attemptMigration was called exactly once verify(mockMigrator, times(1)).attemptMigration() } - - } \ No newline at end of file From d1d2dffbbd744bfe476f17aa3060f9f0d327404a Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:09:55 +0100 Subject: [PATCH 11/13] [MOB-9235] Make encryptor support old versions --- .../IterableDataEncryptorTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index 328fbcf62..50e64a231 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -421,6 +421,61 @@ public void testDecryptManipulatedVersionFlag() { } } + @Test + public void testLegacyEncryptionAndDecryption() { + // Set to API 16 (Legacy) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); + + String testData = "test data for legacy encryption"; + String encrypted = encryptor.encrypt(testData); + String decrypted = encryptor.decrypt(encrypted); + + assertEquals("Legacy encryption/decryption should work on API 16", testData, decrypted); + + // Verify it's using legacy encryption + byte[] encryptedBytes = Base64.decode(encrypted, Base64.NO_WRAP); + assertEquals("Should use legacy encryption flag", 0, encryptedBytes[0]); + + // Test on API 18 + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2); + String decryptedOnApi18 = encryptor.decrypt(encrypted); + assertEquals("Legacy data should be decryptable on API 18", testData, decryptedOnApi18); + + String encryptedOnApi18 = encryptor.encrypt(testData); + String decryptedFromApi18 = encryptor.decrypt(encryptedOnApi18); + assertEquals("API 18 encryption/decryption should work", testData, decryptedFromApi18); + + // Verify API 18 also uses legacy encryption + byte[] api18EncryptedBytes = Base64.decode(encryptedOnApi18, Base64.NO_WRAP); + assertEquals("Should use legacy encryption flag on API 18", 0, api18EncryptedBytes[0]); + } + + @Test + public void testModernEncryptionAndDecryption() { + String testData = "test data for modern encryption"; + + // Test on API 19 (First modern version) + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); + String encryptedOnApi19 = encryptor.encrypt(testData); + String decryptedOnApi19 = encryptor.decrypt(encryptedOnApi19); + assertEquals("Modern encryption should work on API 19", testData, decryptedOnApi19); + + byte[] api19EncryptedBytes = Base64.decode(encryptedOnApi19, Base64.NO_WRAP); + assertEquals("Should use modern encryption flag on API 19", 1, api19EncryptedBytes[0]); + + // Test on API 23 + setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M); + String decryptedOnApi23 = encryptor.decrypt(encryptedOnApi19); + assertEquals("API 19 data should be decryptable on API 23", testData, decryptedOnApi23); + + String encryptedOnApi23 = encryptor.encrypt(testData); + String decryptedFromApi23 = encryptor.decrypt(encryptedOnApi23); + assertEquals("API 23 encryption/decryption should work", testData, decryptedFromApi23); + + byte[] api23EncryptedBytes = Base64.decode(encryptedOnApi23, Base64.NO_WRAP); + assertEquals("Should use modern encryption flag on API 23", 1, api23EncryptedBytes[0]); + } + private static void setFinalStatic(Class clazz, String fieldName, Object newValue) { try { Field field = clazz.getDeclaredField(fieldName); From 1e4a4eec87e609af7b9052354a279f3228e9004f Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:16:20 +0100 Subject: [PATCH 12/13] [MOB-9235] Make encryptor support old versions --- .../IterableDataEncryptorTest.java | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index 50e64a231..a6acd1c0f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -304,28 +304,28 @@ public void testDecryptionAfterKeyLoss() { @Test public void testEncryptionAcrossApiLevels() { String testData = "test data for cross-version compatibility"; - + // Test API 16 (Legacy) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); String encryptedOnApi16 = encryptor.encrypt(testData); - + // Test API 18 (Legacy) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2); String encryptedOnApi18 = encryptor.encrypt(testData); assertEquals("Legacy decryption should work on API 18", testData, encryptor.decrypt(encryptedOnApi16)); - + // Test API 19 (Modern - First version with GCM support) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); String encryptedOnApi19 = encryptor.encrypt(testData); assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi16)); assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi18)); - + // Test API 23 (Modern with KeyStore) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M); String encryptedOnApi23 = encryptor.encrypt(testData); assertEquals("Should decrypt legacy data on API 23", testData, encryptor.decrypt(encryptedOnApi16)); assertEquals("Should decrypt API 19 data on API 23", testData, encryptor.decrypt(encryptedOnApi19)); - + // Test that modern encryption fails on legacy devices setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); try { @@ -347,13 +347,13 @@ public void testEncryptionAcrossApiLevels() { @Test public void testEncryptionMethodFlag() { String testData = "test data for encryption method verification"; - + // Test legacy encryption flag (API 16) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); String legacyEncrypted = encryptor.encrypt(testData); byte[] legacyBytes = Base64.decode(legacyEncrypted, Base64.NO_WRAP); assertEquals("Legacy encryption should have flag 0", 0, legacyBytes[0]); - + // Test modern encryption flag (API 19) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); String modernEncrypted = encryptor.encrypt(testData); @@ -366,11 +366,11 @@ public void testDecryptCorruptData() { String testData = "test data"; String encrypted = encryptor.encrypt(testData); byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); - + // Corrupt the data portion bytes[bytes.length - 1] ^= 0xFF; String corrupted = Base64.encodeToString(bytes, Base64.NO_WRAP); - + try { encryptor.decrypt(corrupted); fail("Should throw exception for corrupted data"); @@ -385,11 +385,11 @@ public void testDecryptManipulatedIV() { String testData = "test data"; String encrypted = encryptor.encrypt(testData); byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); - + // Manipulate the IV bytes[1] ^= 0xFF; // First byte after version flag String manipulated = Base64.encodeToString(bytes, Base64.NO_WRAP); - + try { encryptor.decrypt(manipulated); fail("Should throw exception for manipulated IV"); @@ -403,15 +403,15 @@ public void testDecryptManipulatedIV() { public void testDecryptManipulatedVersionFlag() { // Test on API 16 device setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); - + String testData = "test data"; String encrypted = encryptor.encrypt(testData); byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); - + // Change version flag from legacy (0) to modern (1) bytes[0] = 1; String manipulated = Base64.encodeToString(bytes, Base64.NO_WRAP); - + try { encryptor.decrypt(manipulated); fail("Should throw exception for manipulated version flag"); @@ -425,26 +425,26 @@ public void testDecryptManipulatedVersionFlag() { public void testLegacyEncryptionAndDecryption() { // Set to API 16 (Legacy) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN); - + String testData = "test data for legacy encryption"; String encrypted = encryptor.encrypt(testData); String decrypted = encryptor.decrypt(encrypted); - + assertEquals("Legacy encryption/decryption should work on API 16", testData, decrypted); - + // Verify it's using legacy encryption byte[] encryptedBytes = Base64.decode(encrypted, Base64.NO_WRAP); assertEquals("Should use legacy encryption flag", 0, encryptedBytes[0]); - + // Test on API 18 setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2); String decryptedOnApi18 = encryptor.decrypt(encrypted); assertEquals("Legacy data should be decryptable on API 18", testData, decryptedOnApi18); - + String encryptedOnApi18 = encryptor.encrypt(testData); String decryptedFromApi18 = encryptor.decrypt(encryptedOnApi18); assertEquals("API 18 encryption/decryption should work", testData, decryptedFromApi18); - + // Verify API 18 also uses legacy encryption byte[] api18EncryptedBytes = Base64.decode(encryptedOnApi18, Base64.NO_WRAP); assertEquals("Should use legacy encryption flag on API 18", 0, api18EncryptedBytes[0]); @@ -453,25 +453,25 @@ public void testLegacyEncryptionAndDecryption() { @Test public void testModernEncryptionAndDecryption() { String testData = "test data for modern encryption"; - + // Test on API 19 (First modern version) setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT); String encryptedOnApi19 = encryptor.encrypt(testData); String decryptedOnApi19 = encryptor.decrypt(encryptedOnApi19); assertEquals("Modern encryption should work on API 19", testData, decryptedOnApi19); - + byte[] api19EncryptedBytes = Base64.decode(encryptedOnApi19, Base64.NO_WRAP); assertEquals("Should use modern encryption flag on API 19", 1, api19EncryptedBytes[0]); - + // Test on API 23 setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M); String decryptedOnApi23 = encryptor.decrypt(encryptedOnApi19); assertEquals("API 19 data should be decryptable on API 23", testData, decryptedOnApi23); - + String encryptedOnApi23 = encryptor.encrypt(testData); String decryptedFromApi23 = encryptor.decrypt(encryptedOnApi23); assertEquals("API 23 encryption/decryption should work", testData, decryptedFromApi23); - + byte[] api23EncryptedBytes = Base64.decode(encryptedOnApi23, Base64.NO_WRAP); assertEquals("Should use modern encryption flag on API 23", 1, api23EncryptedBytes[0]); } @@ -480,11 +480,11 @@ private static void setFinalStatic(Class clazz, String fieldName, Object newV try { Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); - + Field modifiersField = Field.class.getDeclaredField("modifiers"); modifiersField.setAccessible(true); modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); - + field.set(null, newValue); } catch (Exception e) { throw new RuntimeException(e); From 186443b15ed44c67eef84e15ba05f5fb191f00ec Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 30 Dec 2024 17:31:40 +0100 Subject: [PATCH 13/13] [MOB-9235] Make encryptor support old versions --- .../IterableDataEncryptorTest.java | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index a6acd1c0f..45ca00f9d 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.fail; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.security.KeyStore; import java.util.ArrayList; @@ -481,9 +482,33 @@ private static void setFinalStatic(Class clazz, String fieldName, Object newV Field field = clazz.getDeclaredField(fieldName); field.setAccessible(true); - Field modifiersField = Field.class.getDeclaredField("modifiers"); - modifiersField.setAccessible(true); - modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + // On Java 8 and lower, use modifiers field + try { + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + } catch (NoSuchFieldException e) { + // On Java 9+, use VarHandle to modify final fields + try { + // Get the internal Field.modifiers field via JDK internal API + Method getDeclaredFields0 = Class.class.getDeclaredMethod("getDeclaredFields0", boolean.class); + getDeclaredFields0.setAccessible(true); + Field[] fields = (Field[]) getDeclaredFields0.invoke(Field.class, false); + Field modifiersField = null; + for (Field f : fields) { + if ("modifiers".equals(f.getName())) { + modifiersField = f; + break; + } + } + if (modifiersField != null) { + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL); + } + } catch (Exception ignored) { + // If all attempts fail, try setting the value anyway + } + } field.set(null, newValue); } catch (Exception e) {