diff --git a/android/src/main/java/com/oblador/keychain/KeychainModule.kt b/android/src/main/java/com/oblador/keychain/KeychainModule.kt index d34edfb7..f49312be 100644 --- a/android/src/main/java/com/oblador/keychain/KeychainModule.kt +++ b/android/src/main/java/com/oblador/keychain/KeychainModule.kt @@ -25,6 +25,8 @@ import com.oblador.keychain.resultHandler.ResultHandlerProvider import com.oblador.keychain.exceptions.KeychainException import com.oblador.keychain.exceptions.EmptyParameterException import com.oblador.keychain.exceptions.KeyStoreAccessException +import javax.crypto.AEADBadTagException +import javax.crypto.BadPaddingException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -278,11 +280,26 @@ class KeychainModule(reactContext: ReactApplicationContext) : credentials.putString(Maps.STORAGE, cipher?.getCipherStorageName()) promise.resolve(credentials) } catch (e: KeyStoreAccessException) { + // Storage access errors not related to missing keys should be surfaced Log.e(KEYCHAIN_MODULE, e.message!!) promise.reject(Errors.E_STORAGE_ACCESS_ERROR, e) } catch (e: KeychainException) { - Log.e(KEYCHAIN_MODULE, e.message!!) - promise.reject(e.errorCode, e) + // Graceful stale handling: treat missing key or tag/padding failures as "not found". + // This avoids side effects after reinstall-and-restore (prefs restored, keystore wiped). + // We keep other errors explicit to avoid hiding genuine corruption/tampering issues. + val cause = e.cause + val isMissingKey = cause is KeyStoreAccessException && (cause.message?.contains("Missing key for alias") == true) + val isTagOrPaddingFailure = cause is AEADBadTagException || cause is BadPaddingException || + (e.message?.contains("Authentication tag verification failed") == true) || + (e.message?.contains("Could not decrypt data") == true) + + if (isMissingKey || isTagOrPaddingFailure) { + Log.w(KEYCHAIN_MODULE, "Graceful stale credentials for service '$alias' -> resolving false") + promise.resolve(false) + } else { + Log.e(KEYCHAIN_MODULE, e.message!!) + promise.reject(e.errorCode, e) + } } catch (fail: Throwable) { Log.e(KEYCHAIN_MODULE, fail.message, fail) promise.reject(Errors.E_INTERNAL_ERROR, fail) diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt index 4956fcfa..25ed5b33 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageBase.kt @@ -244,6 +244,32 @@ abstract class CipherStorageBase(protected val applicationContext: Context) : Ci return key } + /** + * Fetch an existing key for the provided alias without creating a new one. + * + * Why: Reads (decrypt) should not have side effects. Historically, decrypt + * used {@link extractGeneratedKey} which auto-created a key when missing. + * After a reinstall with backups enabled, preferences (ciphertext) can be + * restored while Android Keystore is wiped. Auto-creating a key on decrypt + * cannot recover old ciphertext and unexpectedly mutates observable state + * (new alias appears in keystore listings). Using this helper allows decrypt + * to treat missing keys gracefully (caller can map to "not found") without + * changing keystore state. + * + * @return the existing key or null if the alias does not exist. + */ + @Throws(KeyStoreAccessException::class) + fun getExistingKeyOrNull(alias: String): Key? { + val safeAlias = getDefaultAliasIfEmpty(alias, getDefaultAliasServiceName()) + val keyStore = getKeyStoreAndLoad() + return try { + if (!keyStore.containsAlias(safeAlias)) return null + keyStore.getKey(safeAlias, null) + } catch (fail: Throwable) { + throw KeyStoreAccessException("Could not access Keystore for alias $safeAlias", fail) + } + } + /** Verify that provided key satisfy minimal needed level. */ @Throws(GeneralSecurityException::class) protected fun validateKeySecurityLevel(level: SecurityLevel, key: Key): Boolean { diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt index 346e76cb..194d39ee 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesCbc.kt @@ -11,6 +11,7 @@ import com.oblador.keychain.SecurityLevel import com.oblador.keychain.cipherStorage.CipherStorageKeystoreAesCbc.IV.IV_LENGTH import com.oblador.keychain.resultHandler.ResultHandler import com.oblador.keychain.exceptions.KeychainException +import com.oblador.keychain.exceptions.KeyStoreAccessException import java.io.IOException import java.security.GeneralSecurityException import java.security.Key @@ -117,7 +118,14 @@ class CipherStorageKeystoreAesCbc(reactContext: ReactApplicationContext) : val retries = AtomicInteger(1) try { - val key = extractGeneratedKey(safeAlias, level, retries) + // Do not create key on decrypt; missing key should be treated gracefully by caller. + // See AES-GCM note: avoid creating new aliases during read which would + // change observable state without being able to decrypt restored ciphertext. + val key = getExistingKeyOrNull(safeAlias) + if (key == null) { + handler.onDecrypt(null, KeyStoreAccessException("Missing key for alias: $safeAlias")) + return + } val results = CipherStorage.DecryptionResult( decryptBytes(key, username), decryptBytes(key, password), getSecurityLevel(key) diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt index bb1262dd..7ea0f84b 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreAesGcm.kt @@ -13,6 +13,7 @@ import com.oblador.keychain.resultHandler.CryptoContext import com.oblador.keychain.resultHandler.CryptoOperation import com.oblador.keychain.resultHandler.ResultHandler import com.oblador.keychain.exceptions.KeychainException +import com.oblador.keychain.exceptions.KeyStoreAccessException import java.io.IOException import java.security.GeneralSecurityException import java.security.Key @@ -127,7 +128,17 @@ class CipherStorageKeystoreAesGcm( var key: Key? = null try { - key = extractGeneratedKey(safeAlias, level, retries) + // Do not create key on decrypt; missing key should be treated gracefully by caller. + // Rationale: after app reinstall with backups, prefs may be restored but Keystore + // is wiped. Auto-generating a key here cannot decrypt old ciphertext and would + // mutate state (adding a new alias) which also changes what listing returns. + // Instead, surface a missing-key error to the handler; the module maps it to + // a graceful "not found" for callers by default. + key = getExistingKeyOrNull(safeAlias) + if (key == null) { + handler.onDecrypt(null, KeyStoreAccessException("Missing key for alias: $safeAlias")) + return + } val results = CipherStorage.DecryptionResult( decryptBytes(key, username), decryptBytes(key, password) ) diff --git a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt index 78548622..de65611a 100644 --- a/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt +++ b/android/src/main/java/com/oblador/keychain/cipherStorage/CipherStorageKeystoreRsaEcb.kt @@ -106,8 +106,14 @@ class CipherStorageKeystoreRsaEcb(reactContext: ReactApplicationContext) : var key: Key? = null try { - // key is always NOT NULL otherwise GeneralSecurityException raised - key = extractGeneratedKey(safeAlias, level, retries) + // Do not create key on decrypt; missing key should be treated gracefully by caller. + // Avoid creating a new keypair on read; this would not help decrypt legacy + // ciphertext and would alter keystore listings unexpectedly. + key = getExistingKeyOrNull(safeAlias) + if (key == null) { + handler.onDecrypt(null, KeyStoreAccessException("Missing key for alias: $safeAlias")) + return + } val results = CipherStorage.DecryptionResult(