Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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) {
Expand All @@ -152,91 +140,25 @@ 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)
throw DecryptionException("Failed to decrypt data", e)
}
}

@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() {
Expand Down
Loading
Loading