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 diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt index 1597639cf..2e4e41a40 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,101 @@ 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) + + // 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") + } - // 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) + // Use the appropriate decryption method + val decrypted = if (isModern) { + decryptModern(encryptedData) + } else { + decryptLegacy(encryptedData) + } - return String(cipher.doFinal(encrypted), Charsets.UTF_8) + 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) } } - // Add this method for testing purposes + @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) + 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) + } + + @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(): 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/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) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java index 896c28fdc..45ca00f9d 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,9 @@ 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; @@ -296,4 +301,218 @@ 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]); + } + + @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()); + } + } + + @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