Skip to content

Commit 854fbc9

Browse files
authored
fix: Fixes the IV overwrite when trying to encrypt multiple credentials (#882)
1 parent e111c9d commit 854fbc9

File tree

3 files changed

+464
-60
lines changed

3 files changed

+464
-60
lines changed

auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java

Lines changed: 135 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ class CryptoUtil {
6464
private static final int AES_KEY_SIZE = 256;
6565
private static final int RSA_KEY_SIZE = 2048;
6666

67+
private static final byte FORMAT_MARKER = 0x01;
68+
69+
private static final int GCM_TAG_LENGTH = 16;
70+
private static final int MIN_DATA_LENGTH = 1;
71+
private static final int FORMAT_HEADER_LENGTH = 2;
72+
6773
private final String OLD_KEY_ALIAS;
6874
private final String OLD_KEY_IV_ALIAS;
6975
private final String KEY_ALIAS;
@@ -156,7 +162,9 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe
156162
generator.generateKeyPair();
157163

158164
return getKeyEntryCompat(keyStore, KEY_ALIAS);
159-
} catch (CertificateException | InvalidAlgorithmParameterException | NoSuchProviderException | NoSuchAlgorithmException | KeyStoreException | ProviderException e) {
165+
} catch (CertificateException | InvalidAlgorithmParameterException |
166+
NoSuchProviderException | NoSuchAlgorithmException | KeyStoreException |
167+
ProviderException e) {
160168
/*
161169
* This exceptions are safe to be ignored:
162170
*
@@ -240,7 +248,8 @@ private void deleteRSAKeys() {
240248
keyStore.deleteEntry(KEY_ALIAS);
241249
keyStore.deleteEntry(OLD_KEY_ALIAS);
242250
Log.d(TAG, "Deleting the existing RSA key pair from the KeyStore.");
243-
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
251+
} catch (KeyStoreException | CertificateException | IOException |
252+
NoSuchAlgorithmException e) {
244253
Log.e(TAG, "Failed to remove the RSA KeyEntry from the Android KeyStore.", e);
245254
}
246255
}
@@ -403,7 +412,7 @@ byte[] getAESKey() throws IncompatibleDeviceException, CryptoException {
403412

404413

405414
/**
406-
* Encrypts the given input bytes using a symmetric key (AES).
415+
* Decrypts the given input bytes using a symmetric key (AES).
407416
* The AES key is stored protected by an asymmetric key pair (RSA).
408417
*
409418
* @param encryptedInput the input bytes to decrypt. There's no limit in size.
@@ -415,18 +424,15 @@ public byte[] decrypt(byte[] encryptedInput) throws CryptoException, Incompatibl
415424
try {
416425
SecretKey key = new SecretKeySpec(getAESKey(), ALGORITHM_AES);
417426
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
418-
String encodedIV = storage.retrieveString(KEY_IV_ALIAS);
419-
if (TextUtils.isEmpty(encodedIV)) {
420-
encodedIV = storage.retrieveString(OLD_KEY_IV_ALIAS);
421-
if (TextUtils.isEmpty(encodedIV)) {
422-
//AES key was JUST generated. If anything existed before, should be encrypted again first.
423-
throw new CryptoException("The encryption keys changed recently. You need to re-encrypt something first.", null);
424-
}
427+
428+
// Detect format and decrypt accordingly to maintain backward compatibility
429+
if (isNewFormat(encryptedInput)) {
430+
return decryptNewFormat(encryptedInput, cipher, key);
431+
} else {
432+
return decryptLegacyFormat(encryptedInput, cipher, key);
425433
}
426-
byte[] iv = Base64.decode(encodedIV, Base64.DEFAULT);
427-
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
428-
return cipher.doFinal(encryptedInput);
429-
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e) {
434+
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
435+
InvalidAlgorithmParameterException e) {
430436
/*
431437
* This exceptions are safe to be ignored:
432438
*
@@ -456,12 +462,115 @@ public byte[] decrypt(byte[] encryptedInput) throws CryptoException, Incompatibl
456462
}
457463
}
458464

465+
/**
466+
* Checks if the encrypted input uses the new format with bundled IV.
467+
* New format structure: [FORMAT_MARKER][IV_LENGTH][IV][ENCRYPTED_DATA]
468+
*
469+
* @param encryptedInput the encrypted data to check
470+
* @return true if new format, false if legacy format
471+
*/
472+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
473+
boolean isNewFormat(byte[] encryptedInput) {
474+
475+
// Boundary check
476+
if (encryptedInput == null || encryptedInput.length < 2) {
477+
return false;
478+
}
479+
480+
if (encryptedInput[0] != FORMAT_MARKER) {
481+
return false;
482+
}
483+
484+
// Check IV length is valid for AES-GCM (12 or 16 bytes)
485+
// AES is a 128 block size cipher ,which is 16 bytes
486+
// AES in GCM mode the recommended IV length is 12 bytes.
487+
// This 12-byte IV is then combined with a 4-byte internal counter to form the full 16-byte
488+
// input block for the underlying AES block cipher in counter mode (CTR), which GCM utilizes.
489+
// Thus checking for a 12 or 16 byte length
490+
int ivLength = encryptedInput[1] & 0xFF;
491+
if (ivLength != 12 && ivLength != 16) {
492+
return false;
493+
}
494+
495+
// Verify minimum total length
496+
// Need: marker(1) + length(1) + IV(12-16) + GCM tag(16) + data(1+)
497+
int minLength = FORMAT_HEADER_LENGTH + ivLength + GCM_TAG_LENGTH + MIN_DATA_LENGTH;
498+
return encryptedInput.length >= minLength;
499+
}
500+
501+
/**
502+
* Decrypts data in the new format (IV bundled with encrypted data).
503+
*
504+
* @param encryptedInput the encrypted input in new format
505+
* @param cipher the cipher instance
506+
* @param key the secret key
507+
* @return the decrypted data
508+
* @throws InvalidKeyException if the key is invalid
509+
* @throws InvalidAlgorithmParameterException if the IV is invalid
510+
* @throws IllegalBlockSizeException if the block size is invalid
511+
* @throws BadPaddingException if padding is incorrect
512+
*/
513+
@VisibleForTesting
514+
private byte[] decryptNewFormat(byte[] encryptedInput, Cipher cipher, SecretKey key)
515+
throws InvalidKeyException, InvalidAlgorithmParameterException,
516+
IllegalBlockSizeException, BadPaddingException {
517+
518+
// Read IV length (byte 1)
519+
int ivLength = encryptedInput[1] & 0xFF;
520+
521+
// Extract IV (bytes 2 to 2+ivLength)
522+
byte[] iv = new byte[ivLength];
523+
System.arraycopy(encryptedInput, 2, iv, 0, ivLength);
524+
525+
int encryptedDataOffset = 2 + ivLength;
526+
int encryptedDataLength = encryptedInput.length - encryptedDataOffset;
527+
528+
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
529+
return cipher.doFinal(encryptedInput, encryptedDataOffset, encryptedDataLength);
530+
}
531+
532+
/**
533+
* Decrypts data in the legacy format (IV stored separately in storage).
534+
* This maintains backward compatibility with credentials encrypted before the fix.
535+
*
536+
* @param encryptedInput the encrypted input in legacy format
537+
* @param cipher the cipher instance
538+
* @param key the secret key
539+
* @return the decrypted data
540+
* @throws InvalidKeyException if the key is invalid
541+
* @throws InvalidAlgorithmParameterException if the IV is invalid
542+
* @throws IllegalBlockSizeException if the block size is invalid
543+
* @throws BadPaddingException if padding is incorrect
544+
* @throws CryptoException if the IV cannot be found in storage
545+
*/
546+
@VisibleForTesting
547+
private byte[] decryptLegacyFormat(byte[] encryptedInput, Cipher cipher, SecretKey key)
548+
throws InvalidKeyException, InvalidAlgorithmParameterException,
549+
IllegalBlockSizeException, BadPaddingException, CryptoException {
550+
// Retrieve IV from storage (legacy behavior)
551+
String encodedIV = storage.retrieveString(KEY_IV_ALIAS);
552+
if (TextUtils.isEmpty(encodedIV)) {
553+
encodedIV = storage.retrieveString(OLD_KEY_IV_ALIAS);
554+
if (TextUtils.isEmpty(encodedIV)) {
555+
throw new CryptoException("The encryption keys changed recently. You need to re-encrypt something first.", null);
556+
}
557+
}
558+
559+
byte[] iv = Base64.decode(encodedIV, Base64.DEFAULT);
560+
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
561+
return cipher.doFinal(encryptedInput);
562+
}
563+
459564
/**
460565
* Encrypts the given input bytes using a symmetric key (AES).
461566
* The AES key is stored protected by an asymmetric key pair (RSA).
567+
* <p>
568+
* The encrypted output uses a new format that bundles the IV with the encrypted data
569+
* to prevent IV collision issues when multiple credentials are stored.
570+
* Format: [FORMAT_MARKER(1)][IV_LENGTH(1)][IV(12-16)][ENCRYPTED_DATA(variable)]
462571
*
463572
* @param decryptedInput the input bytes to encrypt. There's no limit in size.
464-
* @return the encrypted output bytes
573+
* @return the encrypted output bytes with bundled IV
465574
* @throws CryptoException if the RSA Key pair was deemed invalid and got deleted. Operation can be retried.
466575
* @throws IncompatibleDeviceException in the event the device can't understand the cryptographic settings required
467576
*/
@@ -471,10 +580,17 @@ public byte[] encrypt(byte[] decryptedInput) throws CryptoException, Incompatibl
471580
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
472581
cipher.init(Cipher.ENCRYPT_MODE, key);
473582
byte[] encrypted = cipher.doFinal(decryptedInput);
474-
byte[] encodedIV = Base64.encode(cipher.getIV(), Base64.DEFAULT);
475-
//Save IV for Decrypt stage
476-
storage.store(KEY_IV_ALIAS, new String(encodedIV, StandardCharsets.UTF_8));
477-
return encrypted;
583+
byte[] iv = cipher.getIV();
584+
585+
// NEW FORMAT: Bundle IV with encrypted data to prevent collision issues
586+
// Format: [FORMAT_MARKER][IV_LENGTH][IV][ENCRYPTED_DATA]
587+
byte[] output = new byte[1 + 1 + iv.length + encrypted.length];
588+
output[0] = FORMAT_MARKER;
589+
output[1] = (byte) iv.length;
590+
System.arraycopy(iv, 0, output, 2, iv.length);
591+
System.arraycopy(encrypted, 0, output, 2 + iv.length, encrypted.length);
592+
593+
return output;
478594
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
479595
/*
480596
* This exceptions are safe to be ignored:

auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
164164
DefaultLocalAuthenticationManagerFactory()
165165
)
166166

167+
167168
/**
168169
* Saves the given credentials in the Storage.
169170
*
@@ -703,20 +704,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
703704
continueGetCredentials(scope, minTtl, parameters, headers, forceRefresh, callback)
704705
}
705706

706-
private val localAuthenticationResultCallback =
707-
{ scope: String?, minTtl: Int, parameters: Map<String, String>, headers: Map<String, String>, forceRefresh: Boolean, callback: Callback<Credentials, CredentialsManagerException> ->
708-
object : Callback<Boolean, CredentialsManagerException> {
709-
override fun onSuccess(result: Boolean) {
710-
continueGetCredentials(
711-
scope, minTtl, parameters, headers, forceRefresh, callback
712-
)
713-
}
714-
715-
override fun onFailure(error: CredentialsManagerException) {
716-
callback.onFailure(error)
717-
}
718-
}
719-
}
720707

721708
/**
722709
* Retrieves API credentials from storage and automatically renews them using the refresh token if the access
@@ -742,6 +729,11 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
742729

743730
if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) {
744731

732+
if (isBiometricSessionValid()) {
733+
continueGetApiCredentials(audience, scope, minTtl, parameters, headers, callback)
734+
return
735+
}
736+
745737
fragmentActivity.get()?.let { fragmentActivity ->
746738
startBiometricAuthentication(
747739
fragmentActivity,
@@ -975,6 +967,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
975967
serialExecutor.execute {
976968
val encryptedEncodedJson = storage.retrieveString(audience)
977969
//Check if existing api credentials are present and valid
970+
978971
encryptedEncodedJson?.let { encryptedEncoded ->
979972
val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT)
980973
val json: String = try {
@@ -1102,6 +1095,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
11021095
CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e
11031096
)
11041097
} catch (e: CryptoException) {
1098+
clearCredentials()
11051099
throw CredentialsManagerException(
11061100
CredentialsManagerException.Code.CRYPTO_EXCEPTION, e
11071101
)
@@ -1203,7 +1197,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
12031197
internal fun isBiometricSessionValid(): Boolean {
12041198
val lastAuth = lastBiometricAuthTime.get()
12051199
if (lastAuth == NO_SESSION) return false // No session exists
1206-
1200+
12071201
val policy = localAuthenticationOptions?.policy ?: BiometricPolicy.Always
12081202
return when (policy) {
12091203
is BiometricPolicy.Session,

0 commit comments

Comments
 (0)