Skip to content

Commit

Permalink
Merge pull request #852 from Iterable/feature/MOB-9235-clean-log
Browse files Browse the repository at this point in the history
[MOB-9235] Make encryptor support older versions of android
  • Loading branch information
evantk91 authored Dec 30, 2024
2 parents 99dc826 + f267d2c commit df90443
Show file tree
Hide file tree
Showing 4 changed files with 351 additions and 53 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:

- run: touch local.properties

- name: Lint Check
run: ./gradlew :iterableapi:lintDebug

- name: Checkstyle
run: ./gradlew :iterableapi:checkstyle :iterableapi-ui:assembleDebug

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@ 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 = "AES/GCM/NoPadding"
private const val TRANSFORMATION_MODERN = "AES/GCM/NoPadding"
private const val TRANSFORMATION_LEGACY = "AES/CBC/PKCS5Padding"
private const val ITERABLE_KEY_ALIAS = "iterable_encryption_key"
private const val GCM_IV_LENGTH = 12
private const val GCM_TAG_LENGTH = 128
private const val IV_LENGTH = 16
private val TEST_KEYSTORE_PASSWORD = "test_password".toCharArray()

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

private fun canUseAndroidKeyStore(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 &&
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
keyStore.type == ANDROID_KEYSTORE
}

@TargetApi(Build.VERSION_CODES.M)
private fun generateAndroidKeyStoreKey(): Unit? {
return try {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)

val keySpec = KeyGenParameterSpec.Builder(
ITERABLE_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()

keyGenerator.init(keySpec)
keyGenerator.generateKey()
Unit
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)

val keySpec = KeyGenParameterSpec.Builder(
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)
.build()

keyGenerator.init(keySpec)
keyGenerator.generateKey()
Unit
} else {
null
}
} catch (e: Exception) {
IterableLogger.e(TAG, "Failed to generate key using AndroidKeyStore", e)
null
Expand All @@ -92,7 +100,7 @@ class IterableDataEncryptor {

private fun generateFallbackKey() {
val keyGenerator = KeyGenerator.getInstance("AES")
keyGenerator.init(256) // 256-bit AES key
keyGenerator.init(256)
val secretKey = keyGenerator.generateKey()

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

class DecryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)

fun resetKeys() {
try {
keyStore.deleteEntry(ITERABLE_KEY_ALIAS)
generateKey()
} catch (e: Exception) {
IterableLogger.e(TAG, "Failed to regenerate key", e)
}
}

fun encrypt(value: String?): String? {
if (value == null) return null

try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getKey())

val iv = cipher.iv
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
val data = value.toByteArray(Charsets.UTF_8)
val encryptedData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
encryptModern(data)
} else {
encryptLegacy(data)
}

// Combine IV and encrypted data
val combined = ByteArray(iv.size + encrypted.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size)
// Combine isModern flag, IV, and encrypted data
val combined = ByteArray(1 + encryptedData.iv.size + encryptedData.data.size)
combined[0] = if (encryptedData.isModernEncryption) 1 else 0
System.arraycopy(encryptedData.iv, 0, combined, 1, encryptedData.iv.size)
System.arraycopy(encryptedData.data, 0, combined, 1 + encryptedData.iv.size, encryptedData.data.size)

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

try {
val combined = Base64.decode(value, Base64.NO_WRAP)

// Extract components
val isModern = combined[0] == 1.toByte()
val iv = combined.copyOfRange(1, 1 + IV_LENGTH)
val encrypted = combined.copyOfRange(1 + IV_LENGTH, 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")
}

// Extract IV
val iv = combined.copyOfRange(0, GCM_IV_LENGTH)
val encrypted = combined.copyOfRange(GCM_IV_LENGTH, combined.size)

val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, getKey(), spec)
// Use the appropriate decryption method
val decrypted = if (isModern) {
decryptModern(encryptedData)
} else {
decryptLegacy(encryptedData)
}

return String(cipher.doFinal(encrypted), Charsets.UTF_8)
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)
}
}

// Add this method for testing purposes
@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)
val iv = generateIV()
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.ENCRYPT_MODE, getKey(), spec)
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()
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(): ByteArray {
val iv = ByteArray(IV_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() {
try {
keyStore.deleteEntry(ITERABLE_KEY_ALIAS)
generateKey()
} catch (e: Exception) {
IterableLogger.e(TAG, "Failed to regenerate key", e)
}
}

@VisibleForTesting
fun getKeyStore(): KeyStore = keyStore
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,12 @@ class IterableKeychain {
}
}
dataMigrator.attemptMigration()
}
IterableLogger.v(TAG, "Migration completed")
}
} catch (e: Exception) {
IterableLogger.w(TAG, "Migration failed, clearing data", e)
handleDecryptionError(e)
}

IterableLogger.v(TAG, "Migration completed")
}

private fun handleDecryptionError(e: Exception? = null) {
Expand Down
Loading

0 comments on commit df90443

Please sign in to comment.