Skip to content

Commit d16166c

Browse files
committed
fix: Fixing the IV overwrite conflict while storing multiple encrypted credentials
1 parent e111c9d commit d16166c

2 files changed

Lines changed: 131 additions & 36 deletions

File tree

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

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ 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+
6769
private final String OLD_KEY_ALIAS;
6870
private final String OLD_KEY_IV_ALIAS;
6971
private final String KEY_ALIAS;
@@ -156,7 +158,9 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe
156158
generator.generateKeyPair();
157159

158160
return getKeyEntryCompat(keyStore, KEY_ALIAS);
159-
} catch (CertificateException | InvalidAlgorithmParameterException | NoSuchProviderException | NoSuchAlgorithmException | KeyStoreException | ProviderException e) {
161+
} catch (CertificateException | InvalidAlgorithmParameterException |
162+
NoSuchProviderException | NoSuchAlgorithmException | KeyStoreException |
163+
ProviderException e) {
160164
/*
161165
* This exceptions are safe to be ignored:
162166
*
@@ -240,7 +244,8 @@ private void deleteRSAKeys() {
240244
keyStore.deleteEntry(KEY_ALIAS);
241245
keyStore.deleteEntry(OLD_KEY_ALIAS);
242246
Log.d(TAG, "Deleting the existing RSA key pair from the KeyStore.");
243-
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
247+
} catch (KeyStoreException | CertificateException | IOException |
248+
NoSuchAlgorithmException e) {
244249
Log.e(TAG, "Failed to remove the RSA KeyEntry from the Android KeyStore.", e);
245250
}
246251
}
@@ -403,7 +408,7 @@ byte[] getAESKey() throws IncompatibleDeviceException, CryptoException {
403408

404409

405410
/**
406-
* Encrypts the given input bytes using a symmetric key (AES).
411+
* Decrypts the given input bytes using a symmetric key (AES).
407412
* The AES key is stored protected by an asymmetric key pair (RSA).
408413
*
409414
* @param encryptedInput the input bytes to decrypt. There's no limit in size.
@@ -415,18 +420,15 @@ public byte[] decrypt(byte[] encryptedInput) throws CryptoException, Incompatibl
415420
try {
416421
SecretKey key = new SecretKeySpec(getAESKey(), ALGORITHM_AES);
417422
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-
}
423+
424+
// Detect format and decrypt accordingly to maintain backward compatibility
425+
if (isNewFormat(encryptedInput)) {
426+
return decryptNewFormat(encryptedInput, cipher, key);
427+
} else {
428+
return decryptLegacyFormat(encryptedInput, cipher, key);
425429
}
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) {
430+
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
431+
InvalidAlgorithmParameterException e) {
430432
/*
431433
* This exceptions are safe to be ignored:
432434
*
@@ -456,12 +458,104 @@ public byte[] decrypt(byte[] encryptedInput) throws CryptoException, Incompatibl
456458
}
457459
}
458460

461+
/**
462+
* Checks if the encrypted input uses the new format with bundled IV.
463+
* New format structure: [FORMAT_MARKER][IV_LENGTH][IV][ENCRYPTED_DATA]
464+
*
465+
* @param encryptedInput the encrypted data to check
466+
* @return true if new format, false if legacy format
467+
*/
468+
@VisibleForTesting
469+
boolean isNewFormat(byte[] encryptedInput) {
470+
if (encryptedInput[0] != FORMAT_MARKER) {
471+
return false;
472+
}
473+
474+
// Check IV length is valid for AES-GCM (12 or 16 bytes)
475+
int ivLength = encryptedInput[1] & 0xFF;
476+
if (ivLength != 12 && ivLength != 16) {
477+
return false;
478+
}
479+
480+
// Verify minimum total length
481+
// Need: marker(1) + length(1) + IV(12-16) + GCM tag(16) + data(1+)
482+
int minLength = 2 + ivLength + 16 + 1;
483+
return encryptedInput.length >= minLength;
484+
}
485+
486+
/**
487+
* Decrypts data in the new format (IV bundled with encrypted data).
488+
*
489+
* @param encryptedInput the encrypted input in new format
490+
* @param cipher the cipher instance
491+
* @param key the secret key
492+
* @return the decrypted data
493+
* @throws InvalidKeyException if the key is invalid
494+
* @throws InvalidAlgorithmParameterException if the IV is invalid
495+
* @throws IllegalBlockSizeException if the block size is invalid
496+
* @throws BadPaddingException if padding is incorrect
497+
*/
498+
@VisibleForTesting
499+
private byte[] decryptNewFormat(byte[] encryptedInput, Cipher cipher, SecretKey key)
500+
throws InvalidKeyException, InvalidAlgorithmParameterException,
501+
IllegalBlockSizeException, BadPaddingException {
502+
503+
// Read IV length (byte 1)
504+
int ivLength = encryptedInput[1] & 0xFF;
505+
506+
// Extract IV (bytes 2 to 2+ivLength)
507+
byte[] iv = new byte[ivLength];
508+
System.arraycopy(encryptedInput, 2, iv, 0, ivLength);
509+
510+
int encryptedDataOffset = 2 + ivLength;
511+
int encryptedDataLength = encryptedInput.length - encryptedDataOffset;
512+
513+
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
514+
return cipher.doFinal(encryptedInput, encryptedDataOffset, encryptedDataLength);
515+
}
516+
517+
/**
518+
* Decrypts data in the legacy format (IV stored separately in storage).
519+
* This maintains backward compatibility with credentials encrypted before the fix.
520+
*
521+
* @param encryptedInput the encrypted input in legacy format
522+
* @param cipher the cipher instance
523+
* @param key the secret key
524+
* @return the decrypted data
525+
* @throws InvalidKeyException if the key is invalid
526+
* @throws InvalidAlgorithmParameterException if the IV is invalid
527+
* @throws IllegalBlockSizeException if the block size is invalid
528+
* @throws BadPaddingException if padding is incorrect
529+
* @throws CryptoException if the IV cannot be found in storage
530+
*/
531+
@VisibleForTesting
532+
private byte[] decryptLegacyFormat(byte[] encryptedInput, Cipher cipher, SecretKey key)
533+
throws InvalidKeyException, InvalidAlgorithmParameterException,
534+
IllegalBlockSizeException, BadPaddingException, CryptoException {
535+
// Retrieve IV from storage (legacy behavior)
536+
String encodedIV = storage.retrieveString(KEY_IV_ALIAS);
537+
if (TextUtils.isEmpty(encodedIV)) {
538+
encodedIV = storage.retrieveString(OLD_KEY_IV_ALIAS);
539+
if (TextUtils.isEmpty(encodedIV)) {
540+
throw new CryptoException("The encryption keys changed recently. You need to re-encrypt something first.", null);
541+
}
542+
}
543+
544+
byte[] iv = Base64.decode(encodedIV, Base64.DEFAULT);
545+
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
546+
return cipher.doFinal(encryptedInput);
547+
}
548+
459549
/**
460550
* Encrypts the given input bytes using a symmetric key (AES).
461551
* The AES key is stored protected by an asymmetric key pair (RSA).
552+
* <p>
553+
* The encrypted output uses a new format that bundles the IV with the encrypted data
554+
* to prevent IV collision issues when multiple credentials are stored.
555+
* Format: [FORMAT_MARKER(1)][IV_LENGTH(1)][IV(12-16)][ENCRYPTED_DATA(variable)]
462556
*
463557
* @param decryptedInput the input bytes to encrypt. There's no limit in size.
464-
* @return the encrypted output bytes
558+
* @return the encrypted output bytes with bundled IV
465559
* @throws CryptoException if the RSA Key pair was deemed invalid and got deleted. Operation can be retried.
466560
* @throws IncompatibleDeviceException in the event the device can't understand the cryptographic settings required
467561
*/
@@ -471,10 +565,17 @@ public byte[] encrypt(byte[] decryptedInput) throws CryptoException, Incompatibl
471565
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
472566
cipher.init(Cipher.ENCRYPT_MODE, key);
473567
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;
568+
byte[] iv = cipher.getIV();
569+
570+
// NEW FORMAT: Bundle IV with encrypted data to prevent collision issues
571+
// Format: [FORMAT_MARKER][IV_LENGTH][IV][ENCRYPTED_DATA]
572+
byte[] output = new byte[1 + 1 + iv.length + encrypted.length];
573+
output[0] = FORMAT_MARKER;
574+
output[1] = (byte) iv.length;
575+
System.arraycopy(iv, 0, output, 2, iv.length);
576+
System.arraycopy(encrypted, 0, output, 2 + iv.length, encrypted.length);
577+
578+
return output;
478579
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
479580
/*
480581
* This exceptions are safe to be ignored:

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

Lines changed: 11 additions & 17 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,10 +967,11 @@ 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 ->
979-
val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT)
972+
val decoded = Base64.decode(encryptedEncoded, Base64.DEFAULT)
980973
val json: String = try {
981-
String(crypto.decrypt(encrypted))
974+
String(crypto.decrypt(decoded))
982975
} catch (e: IncompatibleDeviceException) {
983976
callback.onFailure(
984977
CredentialsManagerException(
@@ -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)