diff --git a/CHANGELOG.md b/CHANGELOG.md index ad35738da..0ec3fa043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fixed `ConcurrentModificationException` crash during device token registration caused by concurrent access to `deviceAttributes`. - Fixed possible `NoSuchMethodException` crash on Android 5-10 caused by using `Map.of()` which is unavailable on those versions +### Removed +- Removed insecure `AES/CBC/PKCS5Padding` encryption from `IterableDataEncryptor`. The SDK now exclusively uses `AES/GCM/NoPadding`. The legacy CBC algorithm was only used on Android versions below KitKat (API 19), which have been unsupported since `minSdkVersion` was raised to 21. + ## [3.7.0] - Replaced the deprecated `AsyncTask`-based push notification handling with `WorkManager` for improved reliability and compatibility with modern Android versions. No action is required. - Fixed lost event tracking and missed API calls with an auto-retry feature for JWT token failures. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt index 98e28698a..b8a09ecf9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt @@ -11,37 +11,26 @@ 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_MODERN = "AES/GCM/NoPadding" - private const val TRANSFORMATION_LEGACY = "AES/CBC/PKCS5Padding" + private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val ITERABLE_KEY_ALIAS = "iterable_encryption_key" private const val GCM_TAG_LENGTH = 128 - private const val GCM_IV_LENGTH = 12 - private const val CBC_IV_LENGTH = 16 - private val TEST_KEYSTORE_PASSWORD = "test_password".toCharArray() + private val FALLBACK_KEYSTORE_PASSWORD = "test_password".toCharArray() private val keyStore: KeyStore by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - try { - KeyStore.getInstance(ANDROID_KEYSTORE).apply { - load(null) - } - } catch (e: Exception) { - IterableLogger.e(TAG, "Failed to initialize AndroidKeyStore", e) - KeyStore.getInstance("PKCS12").apply { - load(null, TEST_KEYSTORE_PASSWORD) - } + try { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) } - } else { + } catch (e: Exception) { + IterableLogger.e(TAG, "Failed to initialize AndroidKeyStore", e) KeyStore.getInstance("PKCS12").apply { - load(null, TEST_KEYSTORE_PASSWORD) + load(null, FALLBACK_KEYSTORE_PASSWORD) } } } @@ -83,8 +72,8 @@ class IterableDataEncryptor { 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) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .build() keyGenerator.init(keySpec) @@ -106,7 +95,7 @@ class IterableDataEncryptor { val keyEntry = KeyStore.SecretKeyEntry(secretKey) val protParam = if (keyStore.type == "PKCS12") { - PasswordProtection(TEST_KEYSTORE_PASSWORD) + PasswordProtection(FALLBACK_KEYSTORE_PASSWORD) } else { null } @@ -115,7 +104,7 @@ class IterableDataEncryptor { private fun getKey(): SecretKey { val protParam = if (keyStore.type == "PKCS12") { - PasswordProtection(TEST_KEYSTORE_PASSWORD) + PasswordProtection(FALLBACK_KEYSTORE_PASSWORD) } else { null } @@ -127,18 +116,17 @@ class IterableDataEncryptor { try { val data = value.toByteArray(Charsets.UTF_8) - val encryptedData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - encryptModern(data) - } else { - encryptLegacy(data) - } - // Combine isModern flag, IV length, IV, and encrypted data - val combined = ByteArray(1 + 1 + encryptedData.iv.size + encryptedData.data.size) - combined[0] = if (encryptedData.isModernEncryption) 1 else 0 - combined[1] = encryptedData.iv.size.toByte() // Store IV length - System.arraycopy(encryptedData.iv, 0, combined, 2, encryptedData.iv.size) - System.arraycopy(encryptedData.data, 0, combined, 2 + encryptedData.iv.size, encryptedData.data.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(data) + + val combined = ByteArray(1 + 1 + iv.size + encrypted.size) + combined[0] = 1 // GCM flag (kept for format compatibility) + combined[1] = iv.size.toByte() + System.arraycopy(iv, 0, combined, 2, iv.size) + System.arraycopy(encrypted, 0, combined, 2 + iv.size, encrypted.size) return Base64.encodeToString(combined, Base64.NO_WRAP) } catch (e: Exception) { @@ -152,30 +140,18 @@ class IterableDataEncryptor { try { val combined = Base64.decode(value, Base64.NO_WRAP) - - // Extract components - val isModern = combined[0] == 1.toByte() + val ivLength = combined[1].toInt() val iv = combined.copyOfRange(2, 2 + ivLength) val encrypted = combined.copyOfRange(2 + ivLength, combined.size) - val encryptedData = EncryptedData(encrypted, iv, isModern) - - // 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) - } + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + val decrypted = cipher.doFinal(encrypted) return String(decrypted, Charsets.UTF_8) } catch (e: DecryptionException) { - // Re-throw DecryptionException directly throw e } catch (e: Exception) { IterableLogger.e(TAG, "Decryption failed", e) @@ -183,60 +159,6 @@ class IterableDataEncryptor { } } - @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) - cipher.init(Cipher.ENCRYPT_MODE, getKey()) - val iv = cipher.iv - 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(isModern = false) - val spec = IvParameterSpec(iv) - cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec) - val encrypted = cipher.doFinal(data) - 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) - 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(isModern: Boolean = false): ByteArray { - val length = if (isModern) GCM_IV_LENGTH else CBC_IV_LENGTH - val iv = ByteArray(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() { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index 45ca00f9d..dbc636bbe 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java @@ -1,7 +1,6 @@ package com.iterable.iterableapi; import android.content.SharedPreferences; -import android.os.Build; import android.util.Base64; import org.junit.Before; @@ -17,9 +16,6 @@ import static org.junit.Assert.assertNotEquals; 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; import java.util.List; @@ -303,63 +299,14 @@ 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 { - 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() { + public void testEncryptionUsesGCM() { 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]); + String encrypted = encryptor.encrypt(testData); + byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); + + assertEquals("GCM flag should be 1", 1, bytes[0]); + assertEquals("IV length byte should be 12 (GCM)", 12, bytes[1]); } @Test @@ -387,8 +334,8 @@ public void testDecryptManipulatedIV() { String encrypted = encryptor.encrypt(testData); byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP); - // Manipulate the IV - bytes[1] ^= 0xFF; // First byte after version flag + // Manipulate the first byte of the IV + bytes[2] ^= 0xFF; String manipulated = Base64.encodeToString(bytes, Base64.NO_WRAP); try { @@ -400,119 +347,4 @@ public void testDecryptManipulatedIV() { } } - @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()); - } - } - - @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); - field.setAccessible(true); - - // 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) { - throw new RuntimeException(e); - } - } } \ No newline at end of file