Skip to content

Commit 275c785

Browse files
committed
Merge conflict resolved
2 parents cec383f + f8f2c0a commit 275c785

File tree

8 files changed

+487
-84
lines changed

8 files changed

+487
-84
lines changed

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.10.0
1+
3.11.0

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Change Log
22

3+
## [3.11.0](https://github.com/auth0/Auth0.Android/tree/3.11.0) (2025-11-24)
4+
[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.10.0...3.11.0)
5+
6+
**Added**
7+
- feat: Added option to pass AuthenticationAPIClient to SecureCredentialsManager class [\#879](https://github.com/auth0/Auth0.Android/pull/879) ([pmathew92](https://github.com/pmathew92))
8+
- feat: add configurable biometric authentication policies for SecureCredentialsManager [\#867](https://github.com/auth0/Auth0.Android/pull/867) ([subhankarmaiti](https://github.com/subhankarmaiti))
9+
10+
**Fixed**
11+
- fix: Fixes the IV overwrite when trying to encrypt multiple credentials [\#882](https://github.com/auth0/Auth0.Android/pull/882) ([pmathew92](https://github.com/pmathew92))
12+
313
## [3.10.0](https://github.com/auth0/Auth0.Android/tree/3.10.0) (2025-09-12)
414
[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.9.1...3.10.0)
515

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll
5252

5353
```gradle
5454
dependencies {
55-
implementation 'com.auth0.android:auth0:3.10.0'
55+
implementation 'com.auth0.android:auth0:3.11.0'
5656
}
5757
```
5858

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,15 +170,12 @@ public abstract class BaseCredentialsManager internal constructor(
170170
if (requiredScope == null) {
171171
return false
172172
}
173-
val storedScopeList = storedScope.orEmpty().split(" ").toMutableList()
174-
175-
if (ignoreOpenid) storedScopeList.remove("openid")
176-
177-
val stored = storedScopeList.toTypedArray()
178-
Arrays.sort(stored)
179-
val required = requiredScope.split(" ").toTypedArray()
180-
Arrays.sort(required)
181-
return !stored.contentEquals(required)
173+
val storedScopes = storedScope.orEmpty().split(" ").filter { it.isNotEmpty() }.toMutableSet()
174+
if (ignoreOpenid) {
175+
storedScopes.remove("openid")
176+
}
177+
val requiredScopes = requiredScope.split(" ").filter { it.isNotEmpty() }.toSet()
178+
return storedScopes != requiredScopes
182179
}
183180

184181
/**
@@ -217,4 +214,4 @@ public abstract class BaseCredentialsManager internal constructor(
217214
// Use audience if scope is null else use a combination of audience and scope
218215
return if (scope == null) audience else "$audience::$scope"
219216
}
220-
}
217+
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,6 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
721721
override fun clearApiCredentials(audience: String, scope: String?) {
722722
val key = getAPICredentialsKey(audience, scope)
723723
storage.remove(key)
724-
Log.d(TAG, "API Credentials for $audience were just removed from the storage")
725724
}
726725

727726
/**

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: 13 additions & 26 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
*
@@ -227,11 +228,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
227228
e
228229
)
229230
} catch (e: CryptoException) {
230-
/*
231-
* If the keys were invalidated in the call above a good new pair is going to be available
232-
* to use on the next call. We clear any existing credentials so #hasValidCredentials returns
233-
* a true value. Retrying this operation will succeed.
234-
*/
235231
clearApiCredentials(audience, scope)
236232
throw CredentialsManagerException(
237233
CredentialsManagerException.Code.CRYPTO_EXCEPTION,
@@ -336,12 +332,10 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
336332

337333
public override val userProfile: UserProfile?
338334
get() {
339-
val credentials: Credentials? = getExistingCredentials()
340-
// Handle null credentials gracefully
341-
if (credentials == null) {
342-
return null
343-
}
344-
return credentials.user
335+
return runCatching {
336+
val credentials: Credentials = getExistingCredentials()
337+
return credentials.user
338+
}.getOrNull()
345339
}
346340

347341
/**
@@ -709,20 +703,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
709703
continueGetCredentials(scope, minTtl, parameters, headers, forceRefresh, callback)
710704
}
711705

712-
private val localAuthenticationResultCallback =
713-
{ scope: String?, minTtl: Int, parameters: Map<String, String>, headers: Map<String, String>, forceRefresh: Boolean, callback: Callback<Credentials, CredentialsManagerException> ->
714-
object : Callback<Boolean, CredentialsManagerException> {
715-
override fun onSuccess(result: Boolean) {
716-
continueGetCredentials(
717-
scope, minTtl, parameters, headers, forceRefresh, callback
718-
)
719-
}
720-
721-
override fun onFailure(error: CredentialsManagerException) {
722-
callback.onFailure(error)
723-
}
724-
}
725-
}
726706

727707
/**
728708
* Retrieves API credentials from storage and automatically renews them using the refresh token if the access
@@ -748,6 +728,11 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
748728

749729
if (fragmentActivity != null && localAuthenticationOptions != null && localAuthenticationManagerFactory != null) {
750730

731+
if (isBiometricSessionValid()) {
732+
continueGetApiCredentials(audience, scope, minTtl, parameters, headers, callback)
733+
return
734+
}
735+
751736
fragmentActivity.get()?.let { fragmentActivity ->
752737
startBiometricAuthentication(
753738
fragmentActivity,
@@ -783,7 +768,6 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
783768
override fun clearApiCredentials(audience: String, scope: String?) {
784769
val key = getAPICredentialsKey(audience, scope)
785770
storage.remove(key)
786-
Log.d(TAG, "API Credentials for $audience were just removed from the storage")
787771
}
788772

789773
/**
@@ -982,6 +966,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
982966
serialExecutor.execute {
983967
val encryptedEncodedJson = storage.retrieveString(getAPICredentialsKey(audience, scope))
984968
//Check if existing api credentials are present and valid
969+
985970
encryptedEncodedJson?.let { encryptedEncoded ->
986971
val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT)
987972
val json: String = try {
@@ -1111,6 +1096,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
11111096
CredentialsManagerException.Code.INCOMPATIBLE_DEVICE, e
11121097
)
11131098
} catch (e: CryptoException) {
1099+
clearCredentials()
11141100
throw CredentialsManagerException(
11151101
CredentialsManagerException.Code.CRYPTO_EXCEPTION, e
11161102
)
@@ -1212,6 +1198,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
12121198
internal fun isBiometricSessionValid(): Boolean {
12131199
val lastAuth = lastBiometricAuthTime.get()
12141200
if (lastAuth == NO_SESSION) return false // No session exists
1201+
12151202
val policy = localAuthenticationOptions?.policy ?: BiometricPolicy.Always
12161203
return when (policy) {
12171204

0 commit comments

Comments
 (0)