Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 <T> runWithTimeout(callable: Callable<T>): 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 {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -115,4 +153,4 @@ class IterableKeychain {

fun getAuthToken() = secureGet(KEY_AUTH_TOKEN)
fun saveAuthToken(authToken: String?) = secureSave(KEY_AUTH_TOKEN, authToken)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ class IterableKeychainTest {

// Mock migration-related SharedPreferences calls
`when`(mockSharedPrefs.contains(any<String>())).thenReturn(false)
`when`(mockSharedPrefs.getBoolean(any<String>(), 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<String>(), any())).thenReturn(null)

// Mock editor.apply() to do nothing
Mockito.doNothing().`when`(mockEditor).apply()

// Create keychain with encryption enabled (default)
keychain = IterableKeychain(
mockContext,
mockDecryptionFailureHandler
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Loading