1212import android .util .Log ;
1313
1414import androidx .annotation .NonNull ;
15+ import androidx .annotation .Nullable ;
1516import androidx .annotation .VisibleForTesting ;
1617
1718import java .io .IOException ;
3940import javax .crypto .NoSuchPaddingException ;
4041import javax .crypto .SecretKey ;
4142import javax .crypto .spec .IvParameterSpec ;
43+ import javax .crypto .spec .OAEPParameterSpec ;
44+ import javax .crypto .spec .PSource ;
4245import javax .crypto .spec .SecretKeySpec ;
4346import javax .security .auth .x500 .X500Principal ;
4447
48+ import java .security .spec .MGF1ParameterSpec ;
49+
4550/**
4651 * Created by lbalmaceda on 8/24/17.
4752 * Class to handle encryption/decryption cryptographic operations using AES and RSA algorithms in devices with API 19 or higher.
@@ -53,7 +58,25 @@ class CryptoUtil {
5358
5459 // Transformations available since API 18
5560 // https://developer.android.com/training/articles/keystore.html#SupportedCiphers
56- private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding" ;
61+ private static final String RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding" ;
62+ /**
63+ * !!! WARNING !!!
64+ * "RSA/ECB/PKCS1Padding" is cryptographically deprecated due to vulnerabilities
65+ * (e.g. Bleichenbacher padding oracle attacks) and MUST NOT be used for encrypting
66+ * new data or for any general-purpose RSA operations.
67+ *
68+ * This transformation exists solely to DECRYPT pre-existing legacy data that was
69+ * originally encrypted with PKCS#1 v1.5 padding, so that it can be re-encrypted
70+ * using the secure OAEP-based {@link #RSA_TRANSFORMATION}. Once all legacy data has
71+ * been migrated, support for this constant and any code paths that use it should be
72+ * removed.
73+ */
74+ // CodeQL suppression: This legacy constant is required for backward compatibility
75+ // to decrypt credentials encrypted with PKCS1 before the migration to OAEP.
76+ // It is only used for decryption (reading old data), never encryption (writing new data).
77+ // This constant will be removed once all users have migrated to OAEP.
78+ @ SuppressWarnings ("java/rsa-without-oaep" )
79+ private static final String LEGACY_PKCS1_RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding" ;
5780 // https://developer.android.com/reference/javax/crypto/Cipher.html
5881 @ SuppressWarnings ("SpellCheckingInspection" )
5982 private static final String AES_TRANSFORMATION = "AES/GCM/NOPADDING" ;
@@ -64,6 +87,17 @@ class CryptoUtil {
6487 private static final int AES_KEY_SIZE = 256 ;
6588 private static final int RSA_KEY_SIZE = 2048 ;
6689
90+ // Explicit OAEP specification for consistent behavior across JCE providers.
91+ // Using SHA-1 for both OAEP hash and MGF1 hash as it's well-supported by Android KeyStore.
92+ // Note: SHA-1 in OAEP/MGF1 context only requires preimage resistance (still secure),
93+ // unlike digital signatures which require collision resistance.
94+ private static final OAEPParameterSpec OAEP_SPEC = new OAEPParameterSpec (
95+ "SHA-1" ,
96+ "MGF1" ,
97+ MGF1ParameterSpec .SHA1 ,
98+ PSource .PSpecified .DEFAULT
99+ );
100+
67101 private static final byte FORMAT_MARKER = 0x01 ;
68102
69103 private static final int GCM_TAG_LENGTH = 16 ;
@@ -91,6 +125,32 @@ public CryptoUtil(@NonNull Context context, @NonNull Storage storage, @NonNull S
91125 this .storage = storage ;
92126 }
93127
128+ /**
129+ * Decrypts data that was encrypted using legacy RSA/PKCS1 padding.
130+ * <p>
131+ * WARNING: This must only be used for decrypting legacy data during migration.
132+ * New code must always use OAEP padding for RSA encryption/decryption.
133+ *
134+ * @param encryptedData The data encrypted with PKCS1 padding
135+ * @param privateKey The private key for decryption
136+ * @return The decrypted data
137+ * @throws NoSuchPaddingException If PKCS1 padding is not available
138+ * @throws NoSuchAlgorithmException If RSA algorithm is not available
139+ * @throws InvalidKeyException If the private key is invalid
140+ * @throws BadPaddingException If the encrypted data has invalid padding
141+ * @throws IllegalBlockSizeException If the encrypted data size is invalid
142+ */
143+ @ NonNull
144+ @ SuppressWarnings ("java/rsa-without-oaep" )
145+ private static byte [] RSADecryptLegacyPKCS1 (@ NonNull byte [] encryptedData ,
146+ @ NonNull PrivateKey privateKey )
147+ throws NoSuchPaddingException , NoSuchAlgorithmException , InvalidKeyException ,
148+ BadPaddingException , IllegalBlockSizeException {
149+ Cipher rsaPkcs1Cipher = Cipher .getInstance (LEGACY_PKCS1_RSA_TRANSFORMATION );
150+ rsaPkcs1Cipher .init (Cipher .DECRYPT_MODE , privateKey );
151+ return rsaPkcs1Cipher .doFinal (encryptedData );
152+ }
153+
94154 /**
95155 * Attempts to recover the existing RSA Private Key entry or generates a new one as secure as
96156 * this device and Android version allows it if none is found.
@@ -130,7 +190,8 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe
130190 .setCertificateNotBefore (start .getTime ())
131191 .setCertificateNotAfter (end .getTime ())
132192 .setKeySize (RSA_KEY_SIZE )
133- .setEncryptionPaddings (KeyProperties .ENCRYPTION_PADDING_RSA_PKCS1 )
193+ .setEncryptionPaddings (KeyProperties .ENCRYPTION_PADDING_RSA_OAEP )
194+ .setDigests (KeyProperties .DIGEST_SHA1 , KeyProperties .DIGEST_SHA256 )
134195 .setBlockModes (KeyProperties .BLOCK_MODE_ECB )
135196 .build ();
136197 } else {
@@ -280,9 +341,9 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry
280341 try {
281342 PrivateKey privateKey = getRSAKeyEntry ().getPrivateKey ();
282343 Cipher cipher = Cipher .getInstance (RSA_TRANSFORMATION );
283- cipher .init (Cipher .DECRYPT_MODE , privateKey );
344+ cipher .init (Cipher .DECRYPT_MODE , privateKey , OAEP_SPEC );
284345 return cipher .doFinal (encryptedInput );
285- } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e ) {
346+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e ) {
286347 /*
287348 * This exceptions are safe to be ignored:
288349 *
@@ -293,6 +354,8 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry
293354 * implements it. Was introduced in API 1.
294355 * - InvalidKeyException:
295356 * Thrown if the given key is inappropriate for initializing this cipher.
357+ * - InvalidAlgorithmParameterException:
358+ * Thrown if the OAEP parameters are invalid or unsupported.
296359 *
297360 * Read more in https://developer.android.com/reference/javax/crypto/Cipher
298361 */
@@ -329,9 +392,9 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry
329392 try {
330393 Certificate certificate = getRSAKeyEntry ().getCertificate ();
331394 Cipher cipher = Cipher .getInstance (RSA_TRANSFORMATION );
332- cipher .init (Cipher .ENCRYPT_MODE , certificate );
395+ cipher .init (Cipher .ENCRYPT_MODE , certificate . getPublicKey (), OAEP_SPEC );
333396 return cipher .doFinal (decryptedInput );
334- } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e ) {
397+ } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException e ) {
335398 /*
336399 * This exceptions are safe to be ignored:
337400 *
@@ -342,6 +405,8 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry
342405 * implements it. Was introduced in API 1.
343406 * - InvalidKeyException:
344407 * Thrown if the given key is inappropriate for initializing this cipher.
408+ * - InvalidAlgorithmParameterException:
409+ * Thrown if the OAEP parameters are invalid or unsupported.
345410 *
346411 * Read more in https://developer.android.com/reference/javax/crypto/Cipher
347412 */
@@ -362,6 +427,83 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry
362427 }
363428 }
364429
430+ /**
431+ * Attempts to migrate legacy PKCS1-encrypted AES key to OAEP format.
432+ * This method tries to decrypt the AES key using legacy PKCS1 padding,
433+ * then re-encrypts it with OAEP and stores it for future use.
434+ *
435+ * @param encryptedAESBytes the encrypted AES key bytes
436+ * @return the decrypted AES key if migration succeeds, or null if migration fails
437+ */
438+ @ Nullable
439+ private byte [] attemptPKCS1Migration (byte [] encryptedAESBytes ) {
440+ try {
441+ KeyStore keyStore = KeyStore .getInstance (ANDROID_KEY_STORE );
442+ keyStore .load (null );
443+
444+ KeyStore .PrivateKeyEntry rsaKey = findRSAKeyEntry (keyStore );
445+ if (rsaKey == null ) {
446+ Log .d (TAG , "No RSA key found for migration" );
447+ return null ;
448+ }
449+
450+ byte [] decryptedAESKey = RSADecryptLegacyPKCS1 (encryptedAESBytes , rsaKey .getPrivateKey ());
451+
452+ if (!isValidAESKeyLength (decryptedAESKey )) {
453+ Log .e (TAG , "Decrypted AES key has invalid length: " + decryptedAESKey .length );
454+ return null ;
455+ }
456+
457+ Log .d (TAG , "PKCS1 migration successful - deleting old keys" );
458+
459+ deleteRSAKeys ();
460+
461+ byte [] encryptedAESWithOAEP = RSAEncrypt (decryptedAESKey );
462+ String encodedEncryptedAES = new String (Base64 .encode (encryptedAESWithOAEP , Base64 .DEFAULT ), StandardCharsets .UTF_8 );
463+ storage .store (KEY_ALIAS , encodedEncryptedAES );
464+
465+ Log .d (TAG , "AES key re-encrypted with OAEP and stored" );
466+ return decryptedAESKey ;
467+
468+ } catch (BadPaddingException | IllegalBlockSizeException e ) {
469+ Log .e (TAG , "PKCS1 decryption failed. Data may be corrupted." , e );
470+ } catch (KeyStoreException | CertificateException | IOException |
471+ NoSuchAlgorithmException | UnrecoverableEntryException |
472+ NoSuchPaddingException | InvalidKeyException e ) {
473+ Log .e (TAG , "Migration failed due to key access error." , e );
474+ } catch (CryptoException e ) {
475+ Log .e (TAG , "Failed to re-encrypt AES key with OAEP." , e );
476+ }
477+ return null ;
478+ }
479+
480+ /**
481+ * Finds the RSA private key entry from KeyStore, checking both current and legacy aliases.
482+ *
483+ * @param keyStore the initialized KeyStore instance
484+ * @return the RSA key entry, or null if not found
485+ */
486+ @ Nullable
487+ private KeyStore .PrivateKeyEntry findRSAKeyEntry (KeyStore keyStore )
488+ throws KeyStoreException , NoSuchAlgorithmException , UnrecoverableEntryException {
489+ if (keyStore .containsAlias (KEY_ALIAS )) {
490+ return getKeyEntryCompat (keyStore , KEY_ALIAS );
491+ } else if (keyStore .containsAlias (OLD_KEY_ALIAS )) {
492+ return getKeyEntryCompat (keyStore , OLD_KEY_ALIAS );
493+ }
494+ return null ;
495+ }
496+
497+ /**
498+ * Validates that the decrypted AES key has the correct length for AES-256.
499+ *
500+ * @param aesKey the decrypted AES key bytes
501+ * @return true if the key is valid (32 bytes), false otherwise
502+ */
503+ private boolean isValidAESKeyLength (byte [] aesKey ) {
504+ return aesKey != null && aesKey .length == AES_KEY_SIZE / 8 ;
505+ }
506+
365507 /**
366508 * Attempts to recover the existing AES Key or generates a new one if none is found.
367509 *
@@ -371,42 +513,131 @@ byte[] RSAEncrypt(byte[] decryptedInput) throws IncompatibleDeviceException, Cry
371513 */
372514 @ VisibleForTesting
373515 byte [] getAESKey () throws IncompatibleDeviceException , CryptoException {
516+ // Step 1: Try to recover existing AES key encrypted with current format (OAEP)
517+ byte [] aesKey = tryRecoverCurrentAESKey ();
518+ if (aesKey != null ) {
519+ return aesKey ;
520+ }
521+
522+ // Step 2: Try to migrate legacy AES key stored at OLD_KEY_ALIAS
523+ aesKey = tryMigrateLegacyAESKey ();
524+ if (aesKey != null ) {
525+ return aesKey ;
526+ }
527+
528+ // Step 3: Generate new AES key
529+ return generateNewAESKey ();
530+ }
531+
532+ /**
533+ * Attempts to recover the AES key stored at KEY_ALIAS using OAEP decryption.
534+ * If OAEP fails, attempts PKCS1 decryption for legacy data migration.
535+ *
536+ * @return the decrypted AES key, or null if no key exists or recovery failed
537+ * @throws IncompatibleDeviceException if the device doesn't support required crypto operations
538+ * and migration also fails
539+ */
540+ @ Nullable
541+ private byte [] tryRecoverCurrentAESKey () throws IncompatibleDeviceException {
374542 String encodedEncryptedAES = storage .retrieveString (KEY_ALIAS );
375543 if (TextUtils .isEmpty (encodedEncryptedAES )) {
376- encodedEncryptedAES = storage . retrieveString ( OLD_KEY_ALIAS ) ;
544+ return null ;
377545 }
378- if (encodedEncryptedAES != null ) {
379- //Return existing key
380- byte [] encryptedAES = Base64 .decode (encodedEncryptedAES , Base64 .DEFAULT );
381- byte [] existingAES = RSADecrypt (encryptedAES );
382- final int aesExpectedLengthInBytes = AES_KEY_SIZE / 8 ;
383- //Prevent returning an 'Empty key' (invalid/corrupted) that was mistakenly saved
384- if (existingAES != null && existingAES .length == aesExpectedLengthInBytes ) {
385- //Key exists and has the right size
386- return existingAES ;
387- }
546+
547+ byte [] encryptedAESBytes = Base64 .decode (encodedEncryptedAES , Base64 .DEFAULT );
548+ CryptoException oaepException = null ;
549+
550+ try {
551+ return RSADecrypt (encryptedAESBytes );
552+ } catch (CryptoException e ) {
553+ // OAEP decryption failed - could be legacy PKCS1 data or device incompatibility
554+ // Store exception to re-throw if migration also fails
555+ oaepException = e ;
556+ Log .d (TAG , "OAEP decryption failed, attempting PKCS1 migration" , e );
557+ }
558+
559+ // OAEP failed - attempt PKCS1 migration
560+ byte [] migratedKey = attemptPKCS1Migration (encryptedAESBytes );
561+ if (migratedKey != null ) {
562+ return migratedKey ;
388563 }
389- //Key doesn't exist. Generate new AES
564+
565+ // Migration failed or wasn't attempted
566+ // If the original error was IncompatibleDeviceException, re-throw it
567+ if (oaepException instanceof IncompatibleDeviceException ) {
568+ throw (IncompatibleDeviceException ) oaepException ;
569+ }
570+
571+ // Recovery failed - clean up corrupted keys
572+ Log .w (TAG , "Could not recover AES key. Deleting corrupted keys." );
573+ deleteRSAKeys ();
574+ deleteAESKeys ();
575+ return null ;
576+ }
577+
578+ /**
579+ * Attempts to migrate a legacy AES key stored at OLD_KEY_ALIAS.
580+ * Decrypts with PKCS1, re-encrypts with OAEP, and stores at KEY_ALIAS.
581+ *
582+ * @return the decrypted AES key if migration succeeds, or null otherwise
583+ */
584+ @ Nullable
585+ private byte [] tryMigrateLegacyAESKey () {
586+ String encodedOldAES = storage .retrieveString (OLD_KEY_ALIAS );
587+ if (TextUtils .isEmpty (encodedOldAES )) {
588+ return null ;
589+ }
590+
591+ try {
592+ byte [] encryptedOldAESBytes = Base64 .decode (encodedOldAES , Base64 .DEFAULT );
593+ KeyStore .PrivateKeyEntry rsaKeyEntry = getRSAKeyEntry ();
594+
595+ byte [] decryptedAESKey = RSADecryptLegacyPKCS1 (encryptedOldAESBytes , rsaKeyEntry .getPrivateKey ());
596+
597+ // Re-encrypt with OAEP and store at new location
598+ byte [] encryptedAESWithOAEP = RSAEncrypt (decryptedAESKey );
599+ String newEncodedEncryptedAES = new String (Base64 .encode (encryptedAESWithOAEP , Base64 .DEFAULT ), StandardCharsets .UTF_8 );
600+ storage .store (KEY_ALIAS , newEncodedEncryptedAES );
601+ storage .remove (OLD_KEY_ALIAS );
602+
603+ Log .d (TAG , "Legacy AES key migrated successfully" );
604+ return decryptedAESKey ;
605+ } catch (CryptoException | NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException |
606+ BadPaddingException | IllegalBlockSizeException | IllegalArgumentException e ) {
607+ Log .e (TAG , "Could not migrate legacy AES key. Will generate new key." , e );
608+ deleteAESKeys ();
609+ return null ;
610+ }
611+ }
612+
613+ /**
614+ * Generates a new AES-256 key, encrypts it with RSA-OAEP, and stores it.
615+ *
616+ * @return the newly generated AES key bytes
617+ * @throws IncompatibleDeviceException if the device doesn't support required algorithms
618+ * @throws CryptoException if key generation or encryption fails unexpectedly
619+ */
620+ private byte [] generateNewAESKey () throws IncompatibleDeviceException , CryptoException {
390621 try {
391622 KeyGenerator keyGen = KeyGenerator .getInstance (ALGORITHM_AES );
392623 keyGen .init (AES_KEY_SIZE );
393- byte [] aes = keyGen .generateKey ().getEncoded ();
394- //Save encrypted encoded version
395- byte [] encryptedAES = RSAEncrypt (aes );
396- String encodedEncryptedAESText = new String (Base64 .encode (encryptedAES , Base64 .DEFAULT ), StandardCharsets .UTF_8 );
397- storage .store (KEY_ALIAS , encodedEncryptedAESText );
398- return aes ;
624+ byte [] decryptedAESKey = keyGen .generateKey ().getEncoded ();
625+
626+ byte [] encryptedNewAES = RSAEncrypt (decryptedAESKey );
627+ String encodedEncryptedNewAESText = new String (Base64 .encode (encryptedNewAES , Base64 .DEFAULT ), StandardCharsets .UTF_8 );
628+ storage .store (KEY_ALIAS , encodedEncryptedNewAESText );
629+
630+ Log .d (TAG , "New AES key generated and stored" );
631+ return decryptedAESKey ;
399632 } catch (NoSuchAlgorithmException e ) {
400- /*
401- * This exceptions are safe to be ignored:
402- *
403- * - NoSuchAlgorithmException:
404- * Thrown if the Algorithm implementation is not available. AES was introduced in API 1
405- *
406- * Read more in https://developer.android.com/reference/javax/crypto/KeyGenerator
407- */
408- Log .e (TAG , "Error while creating the AES key." , e );
633+ Log .e (TAG , "AES algorithm not available." , e );
409634 throw new IncompatibleDeviceException (e );
635+ } catch (CryptoException e ) {
636+ // Re-throw CryptoException and its subclasses (including IncompatibleDeviceException)
637+ throw e ;
638+ } catch (Exception e ) {
639+ Log .e (TAG , "Unexpected error while creating new AES key." , e );
640+ throw new CryptoException ("Unexpected error while creating new AES key." , e );
410641 }
411642 }
412643
0 commit comments