diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 45c77ecb9..793228594 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -144,7 +144,7 @@ IterableKeychain getKeychain() { } if (keychain == null) { try { - keychain = new IterableKeychain(getMainActivityContext(), config.decryptionFailureHandler); + keychain = new IterableKeychain(getMainActivityContext(), config.decryptionFailureHandler, null, config.keychainEncryption); } catch (Exception e) { IterableLogger.e(TAG, "Failed to create IterableKeychain", e); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 91cf8686b..3c2d79641 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -93,6 +93,12 @@ public class IterableConfig { */ final boolean enableEmbeddedMessaging; + /** + * When set to true, disables encryption for keychain storage. + * By default, encryption is enabled for storing sensitive user data. + */ + final boolean keychainEncryption; + /** * Handler for decryption failures of PII information. * Before calling this handler, the SDK will clear the PII information and create new encryption keys @@ -121,6 +127,7 @@ private IterableConfig(Builder builder) { dataRegion = builder.dataRegion; useInMemoryStorageForInApps = builder.useInMemoryStorageForInApps; enableEmbeddedMessaging = builder.enableEmbeddedMessaging; + keychainEncryption = builder.keychainEncryption; decryptionFailureHandler = builder.decryptionFailureHandler; mobileFrameworkInfo = builder.mobileFrameworkInfo; } @@ -141,6 +148,7 @@ public static class Builder { private IterableDataRegion dataRegion = IterableDataRegion.US; private boolean useInMemoryStorageForInApps = false; private boolean enableEmbeddedMessaging = false; + private boolean keychainEncryption = true; private IterableDecryptionFailureHandler decryptionFailureHandler; private IterableAPIMobileFrameworkInfo mobileFrameworkInfo; @@ -288,7 +296,6 @@ public Builder setDataRegion(@NonNull IterableDataRegion dataRegion) { * Set whether the SDK should store in-apps only in memory, or in file storage * @param useInMemoryStorageForInApps `true` will have in-apps be only in memory */ - @NonNull public Builder setUseInMemoryStorageForInApps(boolean useInMemoryStorageForInApps) { this.useInMemoryStorageForInApps = useInMemoryStorageForInApps; @@ -304,6 +311,17 @@ public Builder setEnableEmbeddedMessaging(boolean enableEmbeddedMessaging) { return this; } + /** + * When set to true, disables encryption for Iterable's keychain storage. + * By default, encryption is enabled for storing sensitive user data. + * @param keychainEncryption Whether to disable encryption for keychain + */ + @NonNull + public Builder setKeychainEncryption(boolean keychainEncryption) { + this.keychainEncryption = keychainEncryption; + return this; + } + /** * Set a handler for decryption failures that can be used to handle data recovery * @param handler Decryption failure handler provided by the app diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt index a602a6468..139bc68e1 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt @@ -2,6 +2,9 @@ package com.iterable.iterableapi import android.content.Context import android.content.SharedPreferences +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit class IterableKeychain { companion object { @@ -10,53 +13,73 @@ class IterableKeychain { const val KEY_USER_ID = "iterable-user-id" const val KEY_AUTH_TOKEN = "iterable-auth-token" private const val PLAINTEXT_SUFFIX = "_plaintext" + private const val CRYPTO_OPERATION_TIMEOUT_MS = 500L + private const val KEY_ENCRYPTION_ENABLED = "iterable-encryption-enabled" + + private val cryptoExecutor = Executors.newSingleThreadExecutor() } private var sharedPrefs: SharedPreferences - internal var encryptor: IterableDataEncryptor + internal var encryptor: IterableDataEncryptor? = null private val decryptionFailureHandler: IterableDecryptionFailureHandler? + private var encryption: Boolean @JvmOverloads constructor( context: Context, decryptionFailureHandler: IterableDecryptionFailureHandler? = null, - migrator: IterableKeychainEncryptedDataMigrator? = null + migrator: IterableKeychainEncryptedDataMigrator? = null, + encryption: Boolean = true ) { - this.decryptionFailureHandler = decryptionFailureHandler sharedPrefs = context.getSharedPreferences( IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE ) - encryptor = IterableDataEncryptor() - IterableLogger.v(TAG, "SharedPreferences being used with encryption") + this.decryptionFailureHandler = decryptionFailureHandler + this.encryption = encryption && sharedPrefs.getBoolean(KEY_ENCRYPTION_ENABLED, true) - try { - val dataMigrator = migrator ?: IterableKeychainEncryptedDataMigrator(context, sharedPrefs, this) - if (!dataMigrator.isMigrationCompleted()) { - dataMigrator.setMigrationCompletionCallback { error -> - error?.let { - IterableLogger.w(TAG, "Migration failed", it) - handleDecryptionError(Exception(it)) + if (!encryption) { + IterableLogger.v(TAG, "SharedPreferences being used without encryption") + } else { + encryptor = IterableDataEncryptor() + IterableLogger.v(TAG, "SharedPreferences being used with encryption") + + try { + val dataMigrator = migrator ?: IterableKeychainEncryptedDataMigrator(context, sharedPrefs, this) + if (!dataMigrator.isMigrationCompleted()) { + dataMigrator.setMigrationCompletionCallback { error -> + error?.let { + IterableLogger.w(TAG, "Migration failed", it) + handleDecryptionError(Exception(it)) + } } + dataMigrator.attemptMigration() + IterableLogger.v(TAG, "Migration completed") } - dataMigrator.attemptMigration() - IterableLogger.v(TAG, "Migration completed") - } - } catch (e: Exception) { - IterableLogger.w(TAG, "Migration failed, clearing data", e) - handleDecryptionError(e) + } catch (e: Exception) { + IterableLogger.w(TAG, "Migration failed, clearing data", e) + handleDecryptionError(e) + } } } + private fun runWithTimeout(callable: Callable): T { + return cryptoExecutor.submit(callable).get(CRYPTO_OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + private fun handleDecryptionError(e: Exception? = null) { - IterableLogger.w(TAG, "Decryption failed, clearing all data and regenerating key") + IterableLogger.w(TAG, "Decryption failed, permanently disabling encryption for this device. Please login again.") + + // Permanently disable encryption for this device sharedPrefs.edit() .remove(KEY_EMAIL) .remove(KEY_USER_ID) .remove(KEY_AUTH_TOKEN) + .putBoolean(KEY_ENCRYPTION_ENABLED, false) .apply() - encryptor.resetKeys() + encryption = false + decryptionFailureHandler?.let { handler -> val exception = e ?: Exception("Unknown decryption error") try { @@ -75,13 +98,20 @@ class IterableKeychain { } private fun secureGet(key: String): String? { - // First check if it's stored in plaintext - if (sharedPrefs.getBoolean(key + PLAINTEXT_SUFFIX, false)) { + val hasPlainText = sharedPrefs.getBoolean(key + PLAINTEXT_SUFFIX, false) + if (!encryption) { + if (hasPlainText) { + return sharedPrefs.getString(key, null) + } else { + return null + } + } else if (hasPlainText) { return sharedPrefs.getString(key, null) } + val encryptedValue = sharedPrefs.getString(key, null) ?: return null return try { - sharedPrefs.getString(key, null)?.let { encryptor.decrypt(it) } + encryptor?.let { runWithTimeout { it.decrypt(encryptedValue) } } } catch (e: Exception) { handleDecryptionError(e) null @@ -95,10 +125,18 @@ class IterableKeychain { return } + if (!encryption) { + editor.putString(key, value).putBoolean(key + PLAINTEXT_SUFFIX, true).apply() + return + } + try { - editor.putString(key, encryptor.encrypt(value)) - .remove(key + PLAINTEXT_SUFFIX) - .apply() + encryptor?.let { + val encrypted = runWithTimeout { it.encrypt(value) } + editor.putString(key, encrypted) + .remove(key + PLAINTEXT_SUFFIX) + .apply() + } } catch (e: Exception) { handleDecryptionError(e) editor.putString(key, value) @@ -115,4 +153,4 @@ class IterableKeychain { fun getAuthToken() = secureGet(KEY_AUTH_TOKEN) fun saveAuthToken(authToken: String?) = secureSave(KEY_AUTH_TOKEN, authToken) -} +} \ No newline at end of file diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt index 1d5124815..c64476107 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -20,4 +20,19 @@ class IterableConfigTest { val config: IterableConfig = configBuilder.build() assertThat(config.dataRegion, `is`(IterableDataRegion.EU)) } + + @Test + fun defaultDisableKeychainEncryption() { + val configBuilder: IterableConfig.Builder = IterableConfig.Builder() + val config: IterableConfig = configBuilder.build() + assertTrue(config.keychainEncryption) + } + + @Test + fun setDisableKeychainEncryption() { + val configBuilder: IterableConfig.Builder = IterableConfig.Builder() + .setKeychainEncryption(false) + val config: IterableConfig = configBuilder.build() + assertFalse(config.keychainEncryption) + } } \ No newline at end of file diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 89f856347..08fb042ff 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -62,12 +62,14 @@ class IterableKeychainTest { // Mock migration-related SharedPreferences calls `when`(mockSharedPrefs.contains(any())).thenReturn(false) - `when`(mockSharedPrefs.getBoolean(any(), anyBoolean())).thenReturn(false) + // Mock encryption flag to be true by default + `when`(mockSharedPrefs.getBoolean(eq("iterable-encryption-enabled"), anyBoolean())).thenReturn(true) `when`(mockSharedPrefs.getString(any(), any())).thenReturn(null) // Mock editor.apply() to do nothing Mockito.doNothing().`when`(mockEditor).apply() + // Create keychain with encryption enabled (default) keychain = IterableKeychain( mockContext, mockDecryptionFailureHandler @@ -172,9 +174,6 @@ class IterableKeychainTest { // Verify failure handler was called with any exception verify(mockDecryptionFailureHandler).onDecryptionFailed(any()) - - // Verify encryptor keys were reset - verify(mockEncryptor).resetKeys() assertNull(result) } @@ -193,10 +192,7 @@ class IterableKeychainTest { assertNull(keychain.getAuthToken()) // Verify failure handler was called exactly once for each operation - verify(mockDecryptionFailureHandler, times(3)).onDecryptionFailed(any()) - - // Verify keys were reset for each failure - verify(mockEncryptor, times(3)).resetKeys() + verify(mockDecryptionFailureHandler, times(1)).onDecryptionFailed(any()) } @Test @@ -326,4 +322,52 @@ class IterableKeychainTest { verify(mockEncryptor, never()).encrypt(isNull()) verify(mockEncryptor, never()).decrypt(isNull()) } + + @Test + fun testEncryptionDisabled() { + // Create a new keychain with encryption disabled + val plaintextKeychain = IterableKeychain( + mockContext, + mockDecryptionFailureHandler, + null, + false // encryption = false means encryption is disabled + ) + + val testEmail = "test@example.com" + val testUserId = "user123" + val testToken = "auth-token-123" + + // Mock the SharedPreferences to return plaintext values + `when`(mockSharedPrefs.getString(eq("iterable-email"), isNull())).thenReturn(testEmail) + `when`(mockSharedPrefs.getString(eq("iterable-user-id"), isNull())).thenReturn(testUserId) + `when`(mockSharedPrefs.getString(eq("iterable-auth-token"), isNull())).thenReturn(testToken) + + // Mock plaintext flag checks to return true when encryption is disabled + `when`(mockSharedPrefs.getBoolean(eq("iterable-email_plaintext"), eq(false))).thenReturn(true) + `when`(mockSharedPrefs.getBoolean(eq("iterable-user-id_plaintext"), eq(false))).thenReturn(true) + `when`(mockSharedPrefs.getBoolean(eq("iterable-auth-token_plaintext"), eq(false))).thenReturn(true) + + // Test save operations + plaintextKeychain.saveEmail(testEmail) + plaintextKeychain.saveUserId(testUserId) + plaintextKeychain.saveAuthToken(testToken) + + // Verify values are stored as plaintext + verify(mockEditor).putString(eq("iterable-email"), eq(testEmail)) + verify(mockEditor).putString(eq("iterable-user-id"), eq(testUserId)) + verify(mockEditor).putString(eq("iterable-auth-token"), eq(testToken)) + + // Verify plaintext suffix flags are set for better compatibility + verify(mockEditor).putBoolean(eq("iterable-email_plaintext"), eq(true)) + verify(mockEditor).putBoolean(eq("iterable-user-id_plaintext"), eq(true)) + verify(mockEditor).putBoolean(eq("iterable-auth-token_plaintext"), eq(true)) + + // Test get operations + assertEquals(testEmail, plaintextKeychain.getEmail()) + assertEquals(testUserId, plaintextKeychain.getUserId()) + assertEquals(testToken, plaintextKeychain.getAuthToken()) + + // Verify IterableDataEncryptor was never created + assertNull(plaintextKeychain.encryptor) + } } \ No newline at end of file