From fcb50eec41c395e045607c104a6e9e5711e2cdc4 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 12 Aug 2026 17:09:09 +0530 Subject: [PATCH] fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 --- .../authentication/storage/CryptoUtil.java | 59 ++++++++++++++++--- .../storage/CryptoUtilTest.java | 32 +++++++++- 2 files changed, 80 insertions(+), 11 deletions(-) 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 4ac745467..c2cd9314d 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 @@ -184,7 +184,7 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe X500Principal principal = new X500Principal("CN=Auth0.Android,O=Auth0"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - 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()) @@ -192,8 +192,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); + } + + spec = specBuilder.build(); } else { //Following code is for API 18-22 //Generate new RSA KeyPair and save it on the KeyStore @@ -343,18 +360,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 7e53a8962..426051fc8 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 @@ -898,8 +898,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 = PowerMockito.mock(PrivateKey.class); KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); @@ -911,6 +911,34 @@ 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 = PowerMockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = PowerMockito.mock(KeyStore.PrivateKeyEntry.class); + doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + PowerMockito.mockStatic(Cipher.class); + PowerMockito.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