Skip to content

Commit c10790b

Browse files
sumchatteringclaude
andcommitted
[SDK-984] Remove insecure AES/CBC/PKCS5Padding for new encryption
Always use AES/GCM/NoPadding for encryption. Legacy CBC decryption retained for backward compatibility with existing encrypted data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c42e6bc commit c10790b

2 files changed

Lines changed: 70 additions & 54 deletions

File tree

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ class IterableDataEncryptor {
8383
ITERABLE_KEY_ALIAS,
8484
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
8585
)
86-
.setBlockModes(KeyProperties.BLOCK_MODE_GCM, KeyProperties.BLOCK_MODE_CBC)
87-
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE, KeyProperties.ENCRYPTION_PADDING_PKCS7)
86+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
87+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
8888
.build()
8989

9090
keyGenerator.init(keySpec)
@@ -127,11 +127,10 @@ class IterableDataEncryptor {
127127

128128
try {
129129
val data = value.toByteArray(Charsets.UTF_8)
130-
val encryptedData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
131-
encryptModern(data)
132-
} else {
133-
encryptLegacy(data)
130+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
131+
throw UnsupportedOperationException("Encryption requires Android API 19 (KitKat) or higher")
134132
}
133+
val encryptedData = encryptModern(data)
135134

136135
// Combine isModern flag, IV length, IV, and encrypted data
137136
val combined = ByteArray(1 + 1 + encryptedData.iv.size + encryptedData.data.size)
@@ -185,18 +184,16 @@ class IterableDataEncryptor {
185184

186185
@TargetApi(Build.VERSION_CODES.KITKAT)
187186
private fun encryptModern(data: ByteArray): EncryptedData {
188-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
189-
return encryptLegacy(data)
190-
}
191-
192187
val cipher = Cipher.getInstance(TRANSFORMATION_MODERN)
193188
cipher.init(Cipher.ENCRYPT_MODE, getKey())
194189
val iv = cipher.iv
195190
val encrypted = cipher.doFinal(data)
196191
return EncryptedData(encrypted, iv, true)
197192
}
198193

199-
private fun encryptLegacy(data: ByteArray): EncryptedData {
194+
@Deprecated("Legacy CBC encryption is insecure due to padding oracle vulnerability. Only retained for testing backward compatibility of decryption.")
195+
@VisibleForTesting
196+
fun encryptLegacy(data: ByteArray): EncryptedData {
200197
val cipher = Cipher.getInstance(TRANSFORMATION_LEGACY)
201198
val iv = generateIV(isModern = false)
202199
val spec = IvParameterSpec(iv)

iterableapi/src/test/java/com/iterable/iterableapi/IterableDataEncryptorTest.java

Lines changed: 62 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -306,29 +306,38 @@ public void testDecryptionAfterKeyLoss() {
306306
public void testEncryptionAcrossApiLevels() {
307307
String testData = "test data for cross-version compatibility";
308308

309-
// Test API 16 (Legacy)
310-
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
311-
String encryptedOnApi16 = encryptor.encrypt(testData);
312-
313-
// Test API 18 (Legacy)
314-
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2);
315-
String encryptedOnApi18 = encryptor.encrypt(testData);
316-
assertEquals("Legacy decryption should work on API 18", testData, encryptor.decrypt(encryptedOnApi16));
309+
// Create legacy-encrypted data using encryptLegacy directly for backward compat testing
310+
byte[] testBytes = testData.getBytes(java.nio.charset.StandardCharsets.UTF_8);
311+
IterableDataEncryptor.EncryptedData legacyEncrypted = encryptor.encryptLegacy(testBytes);
312+
// Manually build the combined format: flag(0) + ivLength + iv + data
313+
byte[] combined = new byte[1 + 1 + legacyEncrypted.getIv().length + legacyEncrypted.getData().length];
314+
combined[0] = 0; // legacy flag
315+
combined[1] = (byte) legacyEncrypted.getIv().length;
316+
System.arraycopy(legacyEncrypted.getIv(), 0, combined, 2, legacyEncrypted.getIv().length);
317+
System.arraycopy(legacyEncrypted.getData(), 0, combined, 2 + legacyEncrypted.getIv().length, legacyEncrypted.getData().length);
318+
String legacyEncryptedStr = Base64.encodeToString(combined, Base64.NO_WRAP);
317319

318320
// Test API 19 (Modern - First version with GCM support)
319321
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT);
320322
String encryptedOnApi19 = encryptor.encrypt(testData);
321-
assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi16));
322-
assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(encryptedOnApi18));
323+
assertEquals("Should decrypt legacy data on API 19", testData, encryptor.decrypt(legacyEncryptedStr));
323324

324325
// Test API 23 (Modern with KeyStore)
325326
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M);
326327
String encryptedOnApi23 = encryptor.encrypt(testData);
327-
assertEquals("Should decrypt legacy data on API 23", testData, encryptor.decrypt(encryptedOnApi16));
328+
assertEquals("Should decrypt legacy data on API 23", testData, encryptor.decrypt(legacyEncryptedStr));
328329
assertEquals("Should decrypt API 19 data on API 23", testData, encryptor.decrypt(encryptedOnApi19));
329330

330-
// Test that modern encryption fails on legacy devices
331+
// Test that encryption on pre-KitKat throws UnsupportedOperationException
331332
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
333+
try {
334+
encryptor.encrypt(testData);
335+
fail("Should throw UnsupportedOperationException on pre-KitKat");
336+
} catch (UnsupportedOperationException e) {
337+
assertEquals("Encryption requires Android API 19 (KitKat) or higher", e.getMessage());
338+
}
339+
340+
// Test that modern encryption fails to decrypt on legacy devices
332341
try {
333342
encryptor.decrypt(encryptedOnApi19);
334343
fail("Should not be able to decrypt modern encryption on legacy device");
@@ -349,12 +358,6 @@ public void testEncryptionAcrossApiLevels() {
349358
public void testEncryptionMethodFlag() {
350359
String testData = "test data for encryption method verification";
351360

352-
// Test legacy encryption flag (API 16)
353-
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
354-
String legacyEncrypted = encryptor.encrypt(testData);
355-
byte[] legacyBytes = Base64.decode(legacyEncrypted, Base64.NO_WRAP);
356-
assertEquals("Legacy encryption should have flag 0", 0, legacyBytes[0]);
357-
358361
// Test modern encryption flag (API 19)
359362
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT);
360363
String modernEncrypted = encryptor.encrypt(testData);
@@ -402,17 +405,22 @@ public void testDecryptManipulatedIV() {
402405

403406
@Test
404407
public void testDecryptManipulatedVersionFlag() {
405-
// Test on API 16 device
406-
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
407-
408+
// Create legacy-encrypted data using encryptLegacy directly
408409
String testData = "test data";
409-
String encrypted = encryptor.encrypt(testData);
410-
byte[] bytes = Base64.decode(encrypted, Base64.NO_WRAP);
410+
byte[] testBytes = testData.getBytes(java.nio.charset.StandardCharsets.UTF_8);
411+
IterableDataEncryptor.EncryptedData legacyEncrypted = encryptor.encryptLegacy(testBytes);
412+
byte[] combined = new byte[1 + 1 + legacyEncrypted.getIv().length + legacyEncrypted.getData().length];
413+
combined[0] = 0; // legacy flag
414+
combined[1] = (byte) legacyEncrypted.getIv().length;
415+
System.arraycopy(legacyEncrypted.getIv(), 0, combined, 2, legacyEncrypted.getIv().length);
416+
System.arraycopy(legacyEncrypted.getData(), 0, combined, 2 + legacyEncrypted.getIv().length, legacyEncrypted.getData().length);
411417

412418
// Change version flag from legacy (0) to modern (1)
413-
bytes[0] = 1;
414-
String manipulated = Base64.encodeToString(bytes, Base64.NO_WRAP);
419+
combined[0] = 1;
420+
String manipulated = Base64.encodeToString(combined, Base64.NO_WRAP);
415421

422+
// Test on API 16 device - should fail because modern decryption is not available
423+
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
416424
try {
417425
encryptor.decrypt(manipulated);
418426
fail("Should throw exception for manipulated version flag");
@@ -424,31 +432,42 @@ public void testDecryptManipulatedVersionFlag() {
424432

425433
@Test
426434
public void testLegacyEncryptionAndDecryption() {
427-
// Set to API 16 (Legacy)
428-
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
429-
430435
String testData = "test data for legacy encryption";
431-
String encrypted = encryptor.encrypt(testData);
432-
String decrypted = encryptor.decrypt(encrypted);
433436

434-
assertEquals("Legacy encryption/decryption should work on API 16", testData, decrypted);
437+
// Verify encrypt() throws on pre-KitKat
438+
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN);
439+
try {
440+
encryptor.encrypt(testData);
441+
fail("Should throw UnsupportedOperationException on pre-KitKat");
442+
} catch (UnsupportedOperationException e) {
443+
assertEquals("Encryption requires Android API 19 (KitKat) or higher", e.getMessage());
444+
}
435445

436-
// Verify it's using legacy encryption
437-
byte[] encryptedBytes = Base64.decode(encrypted, Base64.NO_WRAP);
446+
// Create legacy-encrypted data using encryptLegacy directly
447+
byte[] testBytes = testData.getBytes(java.nio.charset.StandardCharsets.UTF_8);
448+
IterableDataEncryptor.EncryptedData legacyEncrypted = encryptor.encryptLegacy(testBytes);
449+
byte[] combined = new byte[1 + 1 + legacyEncrypted.getIv().length + legacyEncrypted.getData().length];
450+
combined[0] = 0; // legacy flag
451+
combined[1] = (byte) legacyEncrypted.getIv().length;
452+
System.arraycopy(legacyEncrypted.getIv(), 0, combined, 2, legacyEncrypted.getIv().length);
453+
System.arraycopy(legacyEncrypted.getData(), 0, combined, 2 + legacyEncrypted.getIv().length, legacyEncrypted.getData().length);
454+
String legacyEncryptedStr = Base64.encodeToString(combined, Base64.NO_WRAP);
455+
456+
// Verify it has legacy encryption flag
457+
byte[] encryptedBytes = Base64.decode(legacyEncryptedStr, Base64.NO_WRAP);
438458
assertEquals("Should use legacy encryption flag", 0, encryptedBytes[0]);
439459

440-
// Test on API 18
441-
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.JELLY_BEAN_MR2);
442-
String decryptedOnApi18 = encryptor.decrypt(encrypted);
443-
assertEquals("Legacy data should be decryptable on API 18", testData, decryptedOnApi18);
460+
// Verify legacy data can still be decrypted on any API level
461+
String decryptedOnLegacy = encryptor.decrypt(legacyEncryptedStr);
462+
assertEquals("Legacy data should be decryptable on legacy device", testData, decryptedOnLegacy);
444463

445-
String encryptedOnApi18 = encryptor.encrypt(testData);
446-
String decryptedFromApi18 = encryptor.decrypt(encryptedOnApi18);
447-
assertEquals("API 18 encryption/decryption should work", testData, decryptedFromApi18);
464+
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.KITKAT);
465+
String decryptedOnApi19 = encryptor.decrypt(legacyEncryptedStr);
466+
assertEquals("Legacy data should be decryptable on API 19", testData, decryptedOnApi19);
448467

449-
// Verify API 18 also uses legacy encryption
450-
byte[] api18EncryptedBytes = Base64.decode(encryptedOnApi18, Base64.NO_WRAP);
451-
assertEquals("Should use legacy encryption flag on API 18", 0, api18EncryptedBytes[0]);
468+
setFinalStatic(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.M);
469+
String decryptedOnApi23 = encryptor.decrypt(legacyEncryptedStr);
470+
assertEquals("Legacy data should be decryptable on API 23", testData, decryptedOnApi23);
452471
}
453472

454473
@Test

0 commit comments

Comments
 (0)