Skip to content

Commit 52b80a8

Browse files
authored
RSA encryption padding change from PKCS1Padding to OAEPWithSHA1And… (#834)
2 parents 640acee + f65ad52 commit 52b80a8

File tree

2 files changed

+547
-60
lines changed

2 files changed

+547
-60
lines changed

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

Lines changed: 264 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import android.util.Log;
1313

1414
import androidx.annotation.NonNull;
15+
import androidx.annotation.Nullable;
1516
import androidx.annotation.VisibleForTesting;
1617

1718
import java.io.IOException;
@@ -39,9 +40,13 @@
3940
import javax.crypto.NoSuchPaddingException;
4041
import javax.crypto.SecretKey;
4142
import javax.crypto.spec.IvParameterSpec;
43+
import javax.crypto.spec.OAEPParameterSpec;
44+
import javax.crypto.spec.PSource;
4245
import javax.crypto.spec.SecretKeySpec;
4346
import 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

Comments
 (0)