Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions android/src/main/java/com/oblador/keychain/KeychainModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading