diff --git a/CHANGELOG.md b/CHANGELOG.md index 823f094bd..fd8c6186a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [3.21.1](https://github.com/auth0/Auth0.Android/tree/3.21.1) (2026-10-13) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.0...3.21.1) + +**Fixed** +- fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 [\#1048](https://github.com/auth0/Auth0.Android/pull/1048) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [4.1.0](https://github.com/auth0/Auth0.Android/tree/4.1.0) (2026-08-27) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.0.1...4.1.0) @@ -7,6 +13,12 @@ - feat: enable ephemeral browsing for Auth Tab [\#1059](https://github.com/auth0/Auth0.Android/pull/1059) ([pmathew92](https://github.com/pmathew92)) - feat(authentication): add organization parameter to resetPassword [\#1038](https://github.com/auth0/Auth0.Android/pull/1038) ([NandanPrabhu](https://github.com/NandanPrabhu)) +## [3.21.1-beta.0](https://github.com/auth0/Auth0.Android/tree/3.21.1-beta.0) (2026-08-18) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.0...3.21.1-beta.0) + +**Fixed** +- fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 [\#1048](https://github.com/auth0/Auth0.Android/pull/1048) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [4.0.1](https://github.com/auth0/Auth0.Android/tree/4.0.1) (2026-07-24) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.0.0...4.0.1) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java index 5e42756e1..3214487ff 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java @@ -179,7 +179,7 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe end.add(Calendar.YEAR, 25); X500Principal principal = new X500Principal("CN=Auth0.Android,O=Auth0"); - AlgorithmParameterSpec spec = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) + KeyGenParameterSpec.Builder specBuilder = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) .setCertificateSubject(principal) .setCertificateSerialNumber(BigInteger.ONE) .setCertificateNotBefore(start.getTime()) @@ -187,8 +187,25 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe .setKeySize(RSA_KEY_SIZE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) .setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256) - .setBlockModes(KeyProperties.BLOCK_MODE_ECB) - .build(); + .setBlockModes(KeyProperties.BLOCK_MODE_ECB); + + /* + * OAEP uses two independent digests: the OAEP digest (authorized above) and the MGF1 + * digest. Our OAEP_SPEC uses MGF1/SHA-1. The setMgf1Digests API only exists on + * API 35+ (VANILLA_ICE_CREAM); below that KeyMint defaults the MGF1 digest + * (historically to SHA-1), so leaving it unset worked. On newer Keystore2 hardware + * the authorized MGF1 digest set is strictly enforced and its default is + * vendor-dependent (some default to SHA-256 only), which rejects our MGF1/SHA-1 + * cipher with INCOMPATIBLE_MGF_DIGEST ("Incompatible padding mode"). Where the API is + * available, authorize SHA-1 (used by the current cipher) and SHA-256 explicitly so + * the key no longer depends on the vendor default and remains compatible if the + * cipher moves to MGF1/SHA-256 in the future. See OAEP_SPEC. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + specBuilder.setMgf1Digests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256); + } + + AlgorithmParameterSpec spec = specBuilder.build(); KeyPairGenerator generator = KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE); generator.initialize(spec); @@ -323,18 +340,42 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SPEC); return cipher.doFinal(encryptedInput); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException - | InvalidAlgorithmParameterException e) { + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + /* + * - InvalidKeyException: + * Thrown if the stored key is inappropriate for initializing this cipher. On newer + * Keystore2 hardware (API 35+) an existing RSA key whose authorized MGF1 digest set + * does not include SHA-1 rejects our MGF1/SHA-1 cipher here, surfacing as + * "Keystore operation failed" caused by INCOMPATIBLE_MGF_DIGEST ("Incompatible + * padding mode"). Such a key was generated by an older SDK before the MGF1 digest + * was authorized explicitly (see getRSAKeyEntry). + * - InvalidAlgorithmParameterException: + * Thrown if the OAEP parameters are invalid or unsupported for this key. + * + * This is NOT a device-level incompatibility -- the key can be deleted and regenerated + * with the MGF1 digest authorized. Delete the RSA key (and the AES key it protected, + * which is now unrecoverable) and wrap as CryptoException so the caller falls through to + * key regeneration instead of permanently blocking the user with INCOMPATIBLE_DEVICE. + * If the device genuinely cannot generate a compatible key, the subsequent + * getRSAKeyEntry() will throw IncompatibleDeviceException, preserving the terminal path. + * + * Read more in https://developer.android.com/reference/javax/crypto/Cipher + */ + Log.e(TAG, "RSA key rejected the cipher parameters (likely MGF1 digest mismatch on Keystore2). Recreating key.", e); + deleteRSAKeys(); + deleteAESKeys(); + throw new CryptoException( + "The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", e); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { /* * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: * Thrown if the transformation is null, empty or invalid, or if no security provider * implements it. Was introduced in API 1. - * - InvalidKeyException: - * Thrown if the given key is inappropriate for initializing this cipher. - * - InvalidAlgorithmParameterException: - * Thrown if the OAEP parameters are invalid or unsupported. + * + * These indicate the algorithm/padding is not available on the device at all -- a real + * device-level incompatibility. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java index e6b1c3412..02a1baaeb 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java @@ -705,8 +705,8 @@ public void shouldRSADecryptData() throws Exception { } @Test - public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSADecrypt() { - Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { + public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidKeyExceptionWhenTryingToRSADecrypt() throws Exception { + Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", CryptoException.class, () -> { byte[] sampleBytes = new byte[0]; PrivateKey privateKey = Mockito.mock(PrivateKey.class); KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); @@ -717,6 +717,33 @@ public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSADecrypt() { cryptoUtil.RSADecrypt(sampleBytes); }); + + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS + "_iv"); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS + "_iv"); + } + + @Test + public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidAlgorithmParameterExceptionWhenTryingToRSADecrypt() throws Exception { + Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", CryptoException.class, () -> { + byte[] sampleBytes = new byte[0]; + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + doThrow(new InvalidAlgorithmParameterException()).when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); + + cryptoUtil.RSADecrypt(sampleBytes); + }); + + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); } @Test