Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.disableKeychainEncryption);
} 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 disableKeychainEncryption;
Comment thread
Ayyanchira marked this conversation as resolved.
Outdated

/**
* 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;
disableKeychainEncryption = builder.disableKeychainEncryption;
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 disableKeychainEncryption = false;
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 disableKeychainEncryption Whether to disable encryption for keychain
*/
@NonNull
public Builder setDisableKeychainEncryption(boolean disableKeychainEncryption) {
this.disableKeychainEncryption = disableKeychainEncryption;
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,44 +13,59 @@ 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 val cryptoExecutor = Executors.newSingleThreadExecutor()
}

private var sharedPrefs: SharedPreferences
internal var encryptor: IterableDataEncryptor
internal var encryptor: IterableDataEncryptor? = null
private val decryptionFailureHandler: IterableDecryptionFailureHandler?
private val encryptionDisabled: Boolean

@JvmOverloads
constructor(
context: Context,
decryptionFailureHandler: IterableDecryptionFailureHandler? = null,
migrator: IterableKeychainEncryptedDataMigrator? = null
migrator: IterableKeychainEncryptedDataMigrator? = null,
encryptionDisabled: Boolean = false
) {
this.decryptionFailureHandler = decryptionFailureHandler
this.encryptionDisabled = encryptionDisabled
sharedPrefs = context.getSharedPreferences(
IterableConstants.SHARED_PREFS_FILE,
Context.MODE_PRIVATE
)
encryptor = IterableDataEncryptor()
IterableLogger.v(TAG, "SharedPreferences being used with encryption")

if (encryptionDisabled) {
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))
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")
sharedPrefs.edit()
Expand All @@ -56,7 +74,12 @@ class IterableKeychain {
.remove(KEY_AUTH_TOKEN)
.apply()

encryptor.resetKeys()
try {
encryptor?.let { runWithTimeout { it.resetKeys(); Unit } }
} catch (ex: Exception) {
IterableLogger.e(TAG, "Failed to reset keys with timeout", ex)
}

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 (encryptionDisabled) {
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 (encryptionDisabled) {
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()
assertFalse(config.disableKeychainEncryption)
}

@Test
fun setDisableKeychainEncryption() {
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
.setDisableKeychainEncryption(true)
val config: IterableConfig = configBuilder.build()
assertTrue(config.disableKeychainEncryption)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,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,
true
)

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)
}
}