Skip to content

Commit df90443

Browse files
authored
Merge pull request #852 from Iterable/feature/MOB-9235-clean-log
[MOB-9235] Make encryptor support older versions of android
2 parents 99dc826 + f267d2c commit df90443

File tree

4 files changed

+351
-53
lines changed

4 files changed

+351
-53
lines changed

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ jobs:
2222

2323
- run: touch local.properties
2424

25+
- name: Lint Check
26+
run: ./gradlew :iterableapi:lintDebug
27+
2528
- name: Checkstyle
2629
run: ./gradlew :iterableapi:checkstyle :iterableapi-ui:assembleDebug
2730

iterableapi/src/main/java/com/iterable/iterableapi/IterableDataEncryptor.kt

Lines changed: 127 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@ import javax.crypto.spec.GCMParameterSpec
1111
import android.os.Build
1212
import java.security.KeyStore.PasswordProtection
1313
import androidx.annotation.VisibleForTesting
14+
import java.security.SecureRandom
15+
import javax.crypto.spec.IvParameterSpec
16+
import android.annotation.TargetApi
1417

1518
class IterableDataEncryptor {
1619
companion object {
1720
private const val TAG = "IterableDataEncryptor"
1821
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
19-
private const val TRANSFORMATION = "AES/GCM/NoPadding"
22+
private const val TRANSFORMATION_MODERN = "AES/GCM/NoPadding"
23+
private const val TRANSFORMATION_LEGACY = "AES/CBC/PKCS5Padding"
2024
private const val ITERABLE_KEY_ALIAS = "iterable_encryption_key"
21-
private const val GCM_IV_LENGTH = 12
2225
private const val GCM_TAG_LENGTH = 128
26+
private const val IV_LENGTH = 16
2327
private val TEST_KEYSTORE_PASSWORD = "test_password".toCharArray()
2428

25-
// Make keyStore static so it's shared across instances
2629
private val keyStore: KeyStore by lazy {
2730
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
2831
try {
@@ -62,28 +65,33 @@ class IterableDataEncryptor {
6265
}
6366

6467
private fun canUseAndroidKeyStore(): Boolean {
65-
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 &&
68+
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
6669
keyStore.type == ANDROID_KEYSTORE
6770
}
6871

72+
@TargetApi(Build.VERSION_CODES.M)
6973
private fun generateAndroidKeyStoreKey(): Unit? {
7074
return try {
71-
val keyGenerator = KeyGenerator.getInstance(
72-
KeyProperties.KEY_ALGORITHM_AES,
73-
ANDROID_KEYSTORE
74-
)
75-
76-
val keySpec = KeyGenParameterSpec.Builder(
77-
ITERABLE_KEY_ALIAS,
78-
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
79-
)
80-
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
81-
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
82-
.build()
83-
84-
keyGenerator.init(keySpec)
85-
keyGenerator.generateKey()
86-
Unit
75+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
76+
val keyGenerator = KeyGenerator.getInstance(
77+
KeyProperties.KEY_ALGORITHM_AES,
78+
ANDROID_KEYSTORE
79+
)
80+
81+
val keySpec = KeyGenParameterSpec.Builder(
82+
ITERABLE_KEY_ALIAS,
83+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
84+
)
85+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM, KeyProperties.BLOCK_MODE_CBC)
86+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE, KeyProperties.ENCRYPTION_PADDING_PKCS7)
87+
.build()
88+
89+
keyGenerator.init(keySpec)
90+
keyGenerator.generateKey()
91+
Unit
92+
} else {
93+
null
94+
}
8795
} catch (e: Exception) {
8896
IterableLogger.e(TAG, "Failed to generate key using AndroidKeyStore", e)
8997
null
@@ -92,7 +100,7 @@ class IterableDataEncryptor {
92100

93101
private fun generateFallbackKey() {
94102
val keyGenerator = KeyGenerator.getInstance("AES")
95-
keyGenerator.init(256) // 256-bit AES key
103+
keyGenerator.init(256)
96104
val secretKey = keyGenerator.generateKey()
97105

98106
val keyEntry = KeyStore.SecretKeyEntry(secretKey)
@@ -113,31 +121,22 @@ class IterableDataEncryptor {
113121
return (keyStore.getEntry(ITERABLE_KEY_ALIAS, protParam) as KeyStore.SecretKeyEntry).secretKey
114122
}
115123

116-
class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)
117-
118-
fun resetKeys() {
119-
try {
120-
keyStore.deleteEntry(ITERABLE_KEY_ALIAS)
121-
generateKey()
122-
} catch (e: Exception) {
123-
IterableLogger.e(TAG, "Failed to regenerate key", e)
124-
}
125-
}
126-
127124
fun encrypt(value: String?): String? {
128125
if (value == null) return null
129126

130127
try {
131-
val cipher = Cipher.getInstance(TRANSFORMATION)
132-
cipher.init(Cipher.ENCRYPT_MODE, getKey())
133-
134-
val iv = cipher.iv
135-
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
128+
val data = value.toByteArray(Charsets.UTF_8)
129+
val encryptedData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
130+
encryptModern(data)
131+
} else {
132+
encryptLegacy(data)
133+
}
136134

137-
// Combine IV and encrypted data
138-
val combined = ByteArray(iv.size + encrypted.size)
139-
System.arraycopy(iv, 0, combined, 0, iv.size)
140-
System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size)
135+
// Combine isModern flag, IV, and encrypted data
136+
val combined = ByteArray(1 + encryptedData.iv.size + encryptedData.data.size)
137+
combined[0] = if (encryptedData.isModernEncryption) 1 else 0
138+
System.arraycopy(encryptedData.iv, 0, combined, 1, encryptedData.iv.size)
139+
System.arraycopy(encryptedData.data, 0, combined, 1 + encryptedData.iv.size, encryptedData.data.size)
141140

142141
return Base64.encodeToString(combined, Base64.NO_WRAP)
143142
} catch (e: Exception) {
@@ -151,23 +150,101 @@ class IterableDataEncryptor {
151150

152151
try {
153152
val combined = Base64.decode(value, Base64.NO_WRAP)
153+
154+
// Extract components
155+
val isModern = combined[0] == 1.toByte()
156+
val iv = combined.copyOfRange(1, 1 + IV_LENGTH)
157+
val encrypted = combined.copyOfRange(1 + IV_LENGTH, combined.size)
158+
159+
val encryptedData = EncryptedData(encrypted, iv, isModern)
160+
161+
// If it's modern encryption and we're on an old device, fail fast
162+
if (isModern && Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
163+
throw DecryptionException("Modern encryption cannot be decrypted on legacy devices")
164+
}
154165

155-
// Extract IV
156-
val iv = combined.copyOfRange(0, GCM_IV_LENGTH)
157-
val encrypted = combined.copyOfRange(GCM_IV_LENGTH, combined.size)
158-
159-
val cipher = Cipher.getInstance(TRANSFORMATION)
160-
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
161-
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
166+
// Use the appropriate decryption method
167+
val decrypted = if (isModern) {
168+
decryptModern(encryptedData)
169+
} else {
170+
decryptLegacy(encryptedData)
171+
}
162172

163-
return String(cipher.doFinal(encrypted), Charsets.UTF_8)
173+
return String(decrypted, Charsets.UTF_8)
174+
} catch (e: DecryptionException) {
175+
// Re-throw DecryptionException directly
176+
throw e
164177
} catch (e: Exception) {
165178
IterableLogger.e(TAG, "Decryption failed", e)
166179
throw DecryptionException("Failed to decrypt data", e)
167180
}
168181
}
169182

170-
// Add this method for testing purposes
183+
@TargetApi(Build.VERSION_CODES.KITKAT)
184+
private fun encryptModern(data: ByteArray): EncryptedData {
185+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
186+
return encryptLegacy(data)
187+
}
188+
189+
val cipher = Cipher.getInstance(TRANSFORMATION_MODERN)
190+
val iv = generateIV()
191+
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
192+
cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec)
193+
val encrypted = cipher.doFinal(data)
194+
return EncryptedData(encrypted, iv, true)
195+
}
196+
197+
private fun encryptLegacy(data: ByteArray): EncryptedData {
198+
val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY)
199+
val iv = generateIV()
200+
val spec = IvParameterSpec(iv)
201+
cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec)
202+
val encrypted = cipher.doFinal(data)
203+
return EncryptedData(encrypted, iv, false)
204+
}
205+
206+
@TargetApi(Build.VERSION_CODES.KITKAT)
207+
private fun decryptModern(encryptedData: EncryptedData): ByteArray {
208+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
209+
throw DecryptionException("Cannot decrypt modern encryption on legacy device")
210+
}
211+
212+
val cipher = Cipher.getInstance(TRANSFORMATION_MODERN)
213+
val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv)
214+
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
215+
return cipher.doFinal(encryptedData.data)
216+
}
217+
218+
private fun decryptLegacy(encryptedData: EncryptedData): ByteArray {
219+
val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY)
220+
val spec = IvParameterSpec(encryptedData.iv)
221+
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
222+
return cipher.doFinal(encryptedData.data)
223+
}
224+
225+
private fun generateIV(): ByteArray {
226+
val iv = ByteArray(IV_LENGTH)
227+
SecureRandom().nextBytes(iv)
228+
return iv
229+
}
230+
231+
data class EncryptedData(
232+
val data: ByteArray,
233+
val iv: ByteArray,
234+
val isModernEncryption: Boolean
235+
)
236+
237+
class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)
238+
239+
fun resetKeys() {
240+
try {
241+
keyStore.deleteEntry(ITERABLE_KEY_ALIAS)
242+
generateKey()
243+
} catch (e: Exception) {
244+
IterableLogger.e(TAG, "Failed to regenerate key", e)
245+
}
246+
}
247+
171248
@VisibleForTesting
172249
fun getKeyStore(): KeyStore = keyStore
173250
}

iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,12 @@ class IterableKeychain {
3939
}
4040
}
4141
dataMigrator.attemptMigration()
42-
}
42+
IterableLogger.v(TAG, "Migration completed")
43+
}
4344
} catch (e: Exception) {
4445
IterableLogger.w(TAG, "Migration failed, clearing data", e)
4546
handleDecryptionError(e)
4647
}
47-
48-
IterableLogger.v(TAG, "Migration completed")
4948
}
5049

5150
private fun handleDecryptionError(e: Exception? = null) {

0 commit comments

Comments
 (0)