Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: parallel set and get operations #691

Merged
merged 2 commits into from
Nov 21, 2024
Merged
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
6 changes: 4 additions & 2 deletions KeychainExample/e2e/utils/matchLoadInfo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { by, element, expect } from 'detox';
import { by, element, waitFor } from 'detox';

export const matchLoadInfo = async (
username: string,
Expand All @@ -20,5 +20,7 @@ export const matchLoadInfo = async (

regexPattern += '.*$';
const regex = new RegExp(regexPattern);
await expect(element(by.text(regex))).toBeVisible();
await waitFor(element(by.text(regex)))
.toBeVisible()
.withTimeout(3000);
};
182 changes: 99 additions & 83 deletions android/src/main/java/com/oblador/keychain/KeychainModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,27 @@ import com.oblador.keychain.exceptions.EmptyParameterException
import com.oblador.keychain.exceptions.KeyStoreAccessException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

@ReactModule(name = KeychainModule.KEYCHAIN_MODULE)
@Suppress("unused")
class KeychainModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
ReactContextBaseJavaModule(reactContext) {
@StringDef(
AccessControl.NONE,
AccessControl.USER_PRESENCE,
AccessControl.BIOMETRY_ANY,
AccessControl.BIOMETRY_CURRENT_SET,
AccessControl.DEVICE_PASSCODE,
AccessControl.APPLICATION_PASSWORD,
AccessControl.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
AccessControl.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE)
AccessControl.NONE,
AccessControl.USER_PRESENCE,
AccessControl.BIOMETRY_ANY,
AccessControl.BIOMETRY_CURRENT_SET,
AccessControl.DEVICE_PASSCODE,
AccessControl.APPLICATION_PASSWORD,
AccessControl.BIOMETRY_ANY_OR_DEVICE_PASSCODE,
AccessControl.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE
)
internal annotation class AccessControl {
companion object {
const val NONE = "None"
Expand Down Expand Up @@ -139,10 +140,11 @@ class KeychainModule(reactContext: ReactApplicationContext) :
private val prefsStorage: PrefsStorageBase

/** Launches a coroutine to perform non-blocking UI operations */
private val coroutineScope = CoroutineScope(Dispatchers.Default)
private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

/** Mutex to prevent concurrent calls to Cipher, which doesn't support multi-threading */
private val mutex = Mutex()
/** Limit parallelism for coroutineScope */
@OptIn(ExperimentalCoroutinesApi::class)
private val serialDispatcher = Dispatchers.Default.limitedParallelism(1)

// endregion
// region Initialization
Expand All @@ -169,14 +171,15 @@ class KeychainModule(reactContext: ReactApplicationContext) :
val instance = best.getCachedInstance()
val isSecure = best.supportsSecureHardware()
val requiredLevel =
if (isSecure) SecurityLevel.SECURE_HARDWARE else SecurityLevel.SECURE_SOFTWARE
if (isSecure) SecurityLevel.SECURE_HARDWARE else SecurityLevel.SECURE_SOFTWARE
best.generateKeyAndStoreUnderAlias(WARMING_UP_ALIAS, requiredLevel)
best.getKeyStoreAndLoad()
Log.v(
KEYCHAIN_MODULE,
"warming up takes: " +
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) +
" ms")
KEYCHAIN_MODULE,
"warming up takes: " +
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) +
" ms"
)
} catch (ex: Throwable) {
Log.e(KEYCHAIN_MODULE, "warming up failed!", ex)
}
Expand Down Expand Up @@ -208,20 +211,20 @@ class KeychainModule(reactContext: ReactApplicationContext) :

// region React Methods
private fun setGenericPassword(
alias: String,
username: String,
password: String,
options: ReadableMap?,
promise: Promise
alias: String,
username: String,
password: String,
options: ReadableMap?,
promise: Promise
) {
coroutineScope.launch {
coroutineScope.launch(serialDispatcher) {
try {
throwIfEmptyLoginPassword(username, password)
val level = getSecurityLevelOrDefault(options)
val storage = getSelectedStorage(options)
throwIfInsufficientLevel(storage, level)
val promptInfo = getPromptInfo(options)
val result = mutex.withLock { encryptToResult(alias, storage, username, password, level, promptInfo) }
val result = encryptToResult(alias, storage, username, password, level, promptInfo)
prefsStorage.storeEncryptedEntry(alias, result)
val results = Arguments.createMap()
results.putString(Maps.SERVICE, alias)
Expand All @@ -242,10 +245,10 @@ class KeychainModule(reactContext: ReactApplicationContext) :

@ReactMethod
fun setGenericPasswordForOptions(
options: ReadableMap?,
username: String,
password: String,
promise: Promise
options: ReadableMap?,
username: String,
password: String,
promise: Promise
) {
val service = getServiceOrDefault(options)
setGenericPassword(service, username, password, options, promise)
Expand All @@ -270,7 +273,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
}

private fun getGenericPassword(alias: String, options: ReadableMap?, promise: Promise) {
coroutineScope.launch {
coroutineScope.launch(serialDispatcher) {
try {
val resultSet = prefsStorage.getEncryptedEntry(alias)
if (resultSet == null) {
Expand All @@ -294,7 +297,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
} else {
getCipherStorageByName(storageName)
}
val decryptionResult = mutex.withLock { decryptCredentials(alias, cipher!!, resultSet, rules, promptInfo) }
val decryptionResult = decryptCredentials(alias, cipher!!, resultSet, rules, promptInfo)
val credentials = Arguments.createMap()
credentials.putString(Maps.SERVICE, alias)
credentials.putString(Maps.USERNAME, decryptionResult.username)
Expand Down Expand Up @@ -404,11 +407,11 @@ class KeychainModule(reactContext: ReactApplicationContext) :

@ReactMethod
fun setInternetCredentialsForServer(
server: String,
username: String,
password: String,
options: ReadableMap?,
promise: Promise
server: String,
username: String,
password: String,
options: ReadableMap?,
promise: Promise
) {
setGenericPassword(server, username, password, options, promise)
}
Expand Down Expand Up @@ -467,11 +470,11 @@ class KeychainModule(reactContext: ReactApplicationContext) :
*/
@Throws(CryptoFailedException::class, KeyStoreAccessException::class)
private fun decryptCredentials(
alias: String,
current: CipherStorage,
resultSet: PrefsStorageBase.ResultSet,
@Rules rules: String,
promptInfo: PromptInfo
alias: String,
current: CipherStorage,
resultSet: PrefsStorageBase.ResultSet,
@Rules rules: String,
promptInfo: PromptInfo
): DecryptionResult {
val storageName = resultSet.cipherStorageName

Expand All @@ -485,9 +488,10 @@ class KeychainModule(reactContext: ReactApplicationContext) :
// first,
// then encrypt it using the current CipherStorage, then store it again and return
val oldStorage =
getCipherStorageByName(storageName)
?: throw KeyStoreAccessException(
"Wrong cipher storage name '$storageName' or cipher not available")
getCipherStorageByName(storageName)
?: throw KeyStoreAccessException(
"Wrong cipher storage name '$storageName' or cipher not available"
)

// decrypt using the older cipher storage
val decryptionResult = decryptToResult(alias, oldStorage, resultSet, promptInfo)
Expand All @@ -497,7 +501,8 @@ class KeychainModule(reactContext: ReactApplicationContext) :
migrateCipherStorage(alias, current, oldStorage, decryptionResult, promptInfo)
} catch (e: CryptoFailedException) {
Log.w(
KEYCHAIN_MODULE, "Migrating to a less safe storage is not allowed. Keeping the old one")
KEYCHAIN_MODULE, "Migrating to a less safe storage is not allowed. Keeping the old one"
)
}
}
return decryptionResult
Expand Down Expand Up @@ -541,8 +546,8 @@ class KeychainModule(reactContext: ReactApplicationContext) :

/** Get instance of handler that resolves access to the keystore on system request. */
private fun getInteractiveHandler(
current: CipherStorage,
promptInfo: PromptInfo
current: CipherStorage,
promptInfo: PromptInfo
): ResultHandler {
val reactContext = reactApplicationContext
return ResultHandlerProvider.getHandler(reactContext, current, promptInfo)
Expand All @@ -551,22 +556,30 @@ class KeychainModule(reactContext: ReactApplicationContext) :
/** Remove key from old storage and add it to the new storage. */
/* package */
@Throws(
KeyStoreAccessException::class, CryptoFailedException::class, IllegalArgumentException::class)
KeyStoreAccessException::class, CryptoFailedException::class, IllegalArgumentException::class
)
fun migrateCipherStorage(
service: String,
newCipherStorage: CipherStorage,
oldCipherStorage: CipherStorage,
decryptionResult: DecryptionResult,
promptInfo: PromptInfo
service: String,
newCipherStorage: CipherStorage,
oldCipherStorage: CipherStorage,
decryptionResult: DecryptionResult,
promptInfo: PromptInfo
) {

val username =
decryptionResult.username ?: throw IllegalArgumentException("Username cannot be null")
decryptionResult.username ?: throw IllegalArgumentException("Username cannot be null")
val password =
decryptionResult.password ?: throw IllegalArgumentException("Password cannot be null")
decryptionResult.password ?: throw IllegalArgumentException("Password cannot be null")
// don't allow to degrade security level when transferring, the new
// storage should be as safe as the old one.
val encryptionResult = encryptToResult(service, newCipherStorage, username, password, decryptionResult.getSecurityLevel(), promptInfo)
val encryptionResult = encryptToResult(
service,
newCipherStorage,
username,
password,
decryptionResult.getSecurityLevel(),
promptInfo
)

// store the encryption result
prefsStorage.storeEncryptedEntry(service, encryptionResult)
Expand All @@ -591,7 +604,7 @@ class KeychainModule(reactContext: ReactApplicationContext) :
fun /* package */ getCipherStorageForCurrentAPILevel(useBiometry: Boolean): CipherStorage {
val currentApiLevel = Build.VERSION.SDK_INT
val isBiometry =
useBiometry && (isFingerprintAuthAvailable || isFaceAuthAvailable || isIrisAuthAvailable)
useBiometry && (isFingerprintAuthAvailable || isFaceAuthAvailable || isIrisAuthAvailable)
var foundCipher: CipherStorage? = null
for (variant in cipherStorageMap.values) {
Log.d(KEYCHAIN_MODULE, "Probe cipher storage: " + variant.getCipherStorageName())
Expand Down Expand Up @@ -628,29 +641,29 @@ class KeychainModule(reactContext: ReactApplicationContext) :
val isFingerprintAuthAvailable: Boolean
/** True - if fingerprint hardware available and configured, otherwise false. */
get() =
DeviceAvailability.isStrongBiometricAuthAvailable(reactApplicationContext) &&
DeviceAvailability.isFingerprintAuthAvailable(reactApplicationContext)
DeviceAvailability.isStrongBiometricAuthAvailable(reactApplicationContext) &&
DeviceAvailability.isFingerprintAuthAvailable(reactApplicationContext)

val isFaceAuthAvailable: Boolean
/** True - if face recognition hardware available and configured, otherwise false. */
get() =
DeviceAvailability.isStrongBiometricAuthAvailable(reactApplicationContext) &&
DeviceAvailability.isFaceAuthAvailable(reactApplicationContext)
DeviceAvailability.isStrongBiometricAuthAvailable(reactApplicationContext) &&
DeviceAvailability.isFaceAuthAvailable(reactApplicationContext)

val isIrisAuthAvailable: Boolean
/** True - if iris recognition hardware available and configured, otherwise false. */
get() =
DeviceAvailability.isStrongBiometricAuthAvailable(reactApplicationContext) &&
DeviceAvailability.isIrisAuthAvailable(reactApplicationContext)
DeviceAvailability.isStrongBiometricAuthAvailable(reactApplicationContext) &&
DeviceAvailability.isIrisAuthAvailable(reactApplicationContext)

val isSecureHardwareAvailable: Boolean
/** Is secured hardware a part of current storage or not. */
get() =
try {
cipherStorageForCurrentAPILevel.supportsSecureHardware()
} catch (e: CryptoFailedException) {
false
}
try {
cipherStorageForCurrentAPILevel.supportsSecureHardware()
} catch (e: CryptoFailedException) {
false
}

/** Resolve storage to security level it provides. */
private fun getSecurityLevel(useBiometry: Boolean): SecurityLevel {
Expand Down Expand Up @@ -736,8 +749,8 @@ class KeychainModule(reactContext: ReactApplicationContext) :
/** Get access control value from options or fallback to default. */
@AccessControl
private fun getAccessControlOrDefault(
options: ReadableMap?,
@AccessControl fallback: String
options: ReadableMap?,
@AccessControl fallback: String
): String {
var accessControl: String? = null
if (null != options && options.hasKey(Maps.ACCESS_CONTROL)) {
Expand Down Expand Up @@ -766,16 +779,16 @@ class KeychainModule(reactContext: ReactApplicationContext) :
/** Is provided access control string matching biometry use request? */
fun getUseBiometry(@AccessControl accessControl: String?): Boolean {
return AccessControl.BIOMETRY_ANY == accessControl ||
AccessControl.BIOMETRY_CURRENT_SET == accessControl ||
AccessControl.BIOMETRY_ANY_OR_DEVICE_PASSCODE == accessControl ||
AccessControl.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE == accessControl
AccessControl.BIOMETRY_CURRENT_SET == accessControl ||
AccessControl.BIOMETRY_ANY_OR_DEVICE_PASSCODE == accessControl ||
AccessControl.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE == accessControl
}

/** Extract user specified prompt info from options. */
private fun getPromptInfo(options: ReadableMap?): PromptInfo {
val promptInfoOptionsMap =
if (options != null && options.hasKey(Maps.AUTH_PROMPT)) options.getMap(Maps.AUTH_PROMPT)
else null
if (options != null && options.hasKey(Maps.AUTH_PROMPT)) options.getMap(Maps.AUTH_PROMPT)
else null
val promptInfoBuilder = PromptInfo.Builder()
if (null != promptInfoOptionsMap && promptInfoOptionsMap.hasKey(AuthPromptOptions.TITLE)) {
val promptInfoTitle = promptInfoOptionsMap.getString(AuthPromptOptions.TITLE)
Expand All @@ -786,7 +799,8 @@ class KeychainModule(reactContext: ReactApplicationContext) :
promptInfoBuilder.setSubtitle(promptInfoSubtitle)
}
if (null != promptInfoOptionsMap &&
promptInfoOptionsMap.hasKey(AuthPromptOptions.DESCRIPTION)) {
promptInfoOptionsMap.hasKey(AuthPromptOptions.DESCRIPTION)
) {
val promptInfoDescription = promptInfoOptionsMap.getString(AuthPromptOptions.DESCRIPTION)
promptInfoBuilder.setDescription(promptInfoDescription)
}
Expand All @@ -796,10 +810,10 @@ class KeychainModule(reactContext: ReactApplicationContext) :
}

/* PromptInfo is only used in Biometric-enabled RSA storage and can only be unlocked by a strong biometric */ promptInfoBuilder
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)

/* Bypass confirmation to avoid KeyStore unlock timeout being exceeded when using passive biometrics */ promptInfoBuilder
.setConfirmationRequired(false)
.setConfirmationRequired(false)
return promptInfoBuilder.build()
}

Expand All @@ -820,10 +834,12 @@ class KeychainModule(reactContext: ReactApplicationContext) :
return
}
throw CryptoFailedException(
String.format(
"Cipher Storage is too weak. Required security level is: %s, but only %s is provided",
level.name,
storage.securityLevel().name))
String.format(
"Cipher Storage is too weak. Required security level is: %s, but only %s is provided",
level.name,
storage.securityLevel().name
)
)
}

private fun getAliasOrDefault(alias: String?): String {
Expand Down
Loading