diff --git a/patches/expo-secure-store/details.md b/patches/expo-secure-store/details.md index e9ef686113ea..d6d176d012a1 100644 --- a/patches/expo-secure-store/details.md +++ b/patches/expo-secure-store/details.md @@ -1,22 +1,76 @@ # `expo-secure-store` patches -### [expo-secure-store+14.2.4+001+additional-config-options.patch](expo-secure-store+14.2.4+001+additional-config-options.patch) +### [expo-secure-store+14.2.4+001+enable-device-fallback.patch](expo-secure-store+14.2.4+001+enable-device-fallback.patch) - Reason: ``` - Expo SecureStore is a great library, but it lacks some of the functionality we need, i.e.: + We need to enable users to use any device screen lock instead of biometrics. + This is not enabled in the SecureStore, so this patch changes the required input to either biometrics or screen lock knowledge if the 'enableDeviceFallback' flag is set to true. + Additionally, support for screen locks can be checked using the new 'canUseDeviceCredentialsAuthentication' method. + ``` + +- Upstream PR/issue: https://github.com/expo/expo/pull/41409 +- E/App issue: https://github.com/Expensify/App/issues/75225 +- PR introducing patch: https://github.com/Expensify/App/pull/76288 + +### [expo-secure-store+14.2.4+002+return-auth-type.patch](expo-secure-store+14.2.4+002+return-auth-type.patch) + +- Reason: + + ``` + This patch adds the `returnUsedAuthenticationType` flag. + When this flag is set to true, the get methods of SecureStore will return a two-element array. + The first value will be the original value returned when this flag is set to false. + The second value is the authentication type used to read the value from the AUTH_TYPE object. + As for the set function, the returned value will simply be AUTH_TYPE. + It uses a pre-defined constant that mimics an enum and can also be imported directly from the app. + ``` + +- Upstream PR/issue: TBA +- E/App issue: https://github.com/Expensify/App/issues/75225 +- PR introducing patch: https://github.com/Expensify/App/pull/76288 + +### [expo-secure-store+14.2.4+003+force-authentication-on-save.patch](expo-secure-store+14.2.4+003+force-authentication-on-save.patch) + +- Reason: + + ``` + The iOS does not require authentication when a value is saved to the keychain. We cannot force iOS to do so. + However, to maintain consistency with Android, setting the 'forceAuthenticationOnSave' flag to true results in a prompt appearing + before the value is saved. This only works in the asynchronous version of the save method. + ``` - - Returning the type of authentication used for accessing the store - - Allowing the fallback to device credentials - - Enabling use of device credentials if no biometrics are enrolled/supported. - - Enforcing iOS to always ask for the authentication when accessing the store (by default iOS skips the authentication if, for example, user unlocked the screen recently with biometrics) - - Letting user know if the key entry is already stored instead of overwriting it without any feedback +- Upstream PR/issue: TBA +- E/App issue: https://github.com/Expensify/App/issues/75225 +- PR introducing patch: https://github.com/Expensify/App/pull/76288 +### [expo-secure-store+14.2.4+004+fail-on-update.patch](expo-secure-store+14.2.4+004+fail-on-update.patch) + +- Reason: - This is fixed with this patch, but instead of hardcoding solution for above needs, the patch adds it into the SecureStore options ``` + If a value already exists in the SecureStore and a new one is saved, the existing value is simply overwritten. + To avoid unexpected behaviour, set the 'failOnUpdate' flag to true to trigger an error when the given key is already in the store. + ``` + +- Upstream PR/issue: TBA +- E/App issue: https://github.com/Expensify/App/issues/75225 +- PR introducing patch: https://github.com/Expensify/App/pull/76288 + +### [expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch](expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch) + +- Reason: + + ``` + The LocalAuthentication behaves slightly differently on iOS simulators. + In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). + Setting 'forceReadAuthenticationOnSimulators' flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. + The flag is added is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. + It has no effect on real devices. + ``` + +- Upstream PR/issue: TBA +- E/App issue: https://github.com/Expensify/App/issues/75225 +- PR introducing patch: https://github.com/Expensify/App/pull/76288 -- Upstream PR/issue: 🛑 -- E/App issue: No issue, this patch adjust the library for our needs. -- PR introducing patch: https://github.com/Expensify/App/pull/69863 \ No newline at end of file diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+001+additional-config-options.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+001+additional-config-options.patch deleted file mode 100644 index d766eb5c0716..000000000000 --- a/patches/expo-secure-store/expo-secure-store+14.2.4+001+additional-config-options.patch +++ /dev/null @@ -1,1179 +0,0 @@ -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -index a281b88..dc84eb4 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt -@@ -2,6 +2,7 @@ package expo.modules.securestore - - import android.annotation.SuppressLint - import android.app.Activity -+import android.app.KeyguardManager - import android.content.Context - import android.os.Build - import androidx.biometric.BiometricManager -@@ -19,17 +20,18 @@ class AuthenticationHelper( - ) { - private var isAuthenticating = false - -- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String): Cipher { -- if (requiresAuthentication) { -- return openAuthenticationPrompt(cipher, title).cryptoObject?.cipher -- ?: throw AuthenticationException("Couldn't get cipher from authentication result") -+ suspend fun authenticateCipher(cipher: Cipher, title: String, enableCredentialsAlternative: Boolean): BiometricPrompt.AuthenticationResult { -+ val promptResult = openAuthenticationPrompt(cipher, title, enableCredentialsAlternative) -+ if (promptResult.cryptoObject?.cipher == null) { -+ throw AuthenticationException("Couldn't get cipher from authentication result") - } -- return cipher -+ return promptResult - } - - private suspend fun openAuthenticationPrompt( - cipher: Cipher, -- title: String -+ title: String, -+ enableCredentialsAlternative: Boolean - ): BiometricPrompt.AuthenticationResult { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - throw AuthenticationException("Biometric authentication requires Android API 23") -@@ -41,11 +43,16 @@ class AuthenticationHelper( - isAuthenticating = true - - try { -- assertBiometricsSupport() -+ if (enableCredentialsAlternative) { -+ assertDeviceSecurity() -+ } else { -+ assertBiometricsSupport() -+ } -+ - val fragmentActivity = getCurrentActivity() as? FragmentActivity - ?: throw AuthenticationException("Cannot display biometric prompt when the app is not in the foreground") - -- val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title) -+ val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title, enableCredentialsAlternative) - - return withContext(Dispatchers.Main.immediate) { - return@withContext authenticationPrompt.authenticate(cipher) -@@ -56,6 +63,14 @@ class AuthenticationHelper( - } - } - -+ fun assertDeviceSecurity() { -+ val manager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager -+ val isSecure = manager.isDeviceSecure -+ if (!isSecure) { -+ throw AuthenticationException("No authentication method available") -+ } -+ } -+ - fun assertBiometricsSupport() { - val biometricManager = BiometricManager.from(context) - @SuppressLint("SwitchIntDef") // BiometricManager.BIOMETRIC_SUCCESS shouldn't do anything -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -index e5729cc..0fd4242 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt -@@ -1,5 +1,7 @@ - package expo.modules.securestore - -+import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG -+import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL - import android.content.Context - import androidx.biometric.BiometricPrompt - import androidx.biometric.BiometricPrompt.PromptInfo -@@ -11,11 +13,12 @@ import kotlin.coroutines.resume - import kotlin.coroutines.resumeWithException - import kotlin.coroutines.suspendCoroutine - --class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) { -+class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String, enableCredentialsAlternative: Boolean) { -+ private var authType: Int = if (enableCredentialsAlternative) BIOMETRIC_STRONG or DEVICE_CREDENTIAL else BIOMETRIC_STRONG - private var executor: Executor = ContextCompat.getMainExecutor(context) - private var promptInfo = PromptInfo.Builder() - .setTitle(title) -- .setNegativeButtonText(context.getString(android.R.string.cancel)) -+ .setAllowedAuthenticators(authType) - .build() - - suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? = -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -index 0fef884..822caa5 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt -@@ -36,23 +36,23 @@ open class SecureStoreModule : Module() { - - AsyncFunction("setValueWithKeyAsync") Coroutine { value: String?, key: String?, options: SecureStoreOptions -> - key ?: throw NullKeyException() -- return@Coroutine setItemImpl(key, value, options, false) -+ return@Coroutine setItemImpl(key, value, options, false).ordinal - } - - AsyncFunction("getValueWithKeyAsync") Coroutine { key: String, options: SecureStoreOptions -> -- return@Coroutine getItemImpl(key, options) -+ return@Coroutine getItemImpl(key, options).values - } - - Function("setValueWithKeySync") { value: String?, key: String?, options: SecureStoreOptions -> - key ?: throw NullKeyException() - return@Function runBlocking { -- return@runBlocking setItemImpl(key, value, options, keyIsInvalidated = false) -+ return@runBlocking setItemImpl(key, value, options, keyIsInvalidated = false).ordinal - } - } - - Function("getValueWithKeySync") { key: String, options: SecureStoreOptions -> - return@Function runBlocking { -- return@runBlocking getItemImpl(key, options) -+ return@runBlocking getItemImpl(key, options).values - } - } - -@@ -85,7 +85,7 @@ open class SecureStoreModule : Module() { - } - } - -- private suspend fun getItemImpl(key: String, options: SecureStoreOptions): String? { -+ private suspend fun getItemImpl(key: String, options: SecureStoreOptions): SecureStoreFeedback { - // We use a SecureStore-specific shared preferences file, which lets us do things like enumerate - // its entries or clear all of them - val prefs: SharedPreferences = getSharedPreferences() -@@ -95,10 +95,10 @@ open class SecureStoreModule : Module() { - } else if (prefs.contains(key)) { // For backwards-compatibility try to read using the old key format - return readJSONEncodedItem(key, prefs, options) - } -- return null -+ return SecureStoreFeedback(null) - } - -- private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): String? { -+ private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): SecureStoreFeedback { - val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) - - val legacyEncryptedItemString = prefs.getString(key, null) -@@ -117,7 +117,7 @@ open class SecureStoreModule : Module() { - "" - } - -- encryptedItemString ?: return null -+ encryptedItemString ?: return SecureStoreFeedback(null) - - val encryptedItem: JSONObject = try { - JSONObject(encryptedItemString) -@@ -140,22 +140,26 @@ open class SecureStoreModule : Module() { - "This situation occurs when the app is reinstalled. The value will be removed to avoid future errors. Returning null" - ) - deleteItemImpl(key, options) -- return null -+ return SecureStoreFeedback(null) - } -+ - return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper) - } -+ - HybridAESEncryptor.NAME -> { - val privateKeyEntry = getKeyEntryCompat(PrivateKeyEntry::class.java, hybridAESEncryptor, options, requireAuthentication, usesKeystoreSuffix) -- ?: return null -+ ?: return SecureStoreFeedback(null) -+ - return hybridAESEncryptor.decryptItem(key, encryptedItem, privateKeyEntry, options, authenticationHelper) - } -+ - else -> { - throw DecryptException("The item for key $key in SecureStore has an unknown encoding scheme $scheme)", key, options.keychainService) - } - } - } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "The requested key has been permanently invalidated. Returning null") -- return null -+ return SecureStoreFeedback(null) - } catch (e: BadPaddingException) { - // The key from the KeyStore is unable to decode the entry. This is because a new key was generated, but the entries are encrypted using the old one. - // This usually means that the user has reinstalled the app. We can safely remove the old value and return null as it's impossible to decrypt it. -@@ -165,7 +169,7 @@ open class SecureStoreModule : Module() { - "The entry in shared preferences is out of sync with the keystore. It will be removed, returning null." - ) - deleteItemImpl(key, options) -- return null -+ return SecureStoreFeedback(null) - } catch (e: GeneralSecurityException) { - throw (DecryptException(e.message, key, options.keychainService, e)) - } catch (e: CodedException) { -@@ -175,7 +179,7 @@ open class SecureStoreModule : Module() { - } - } - -- private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean) { -+ private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean): SecureStoreAuthType { - val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) - val prefs: SharedPreferences = getSharedPreferences() - -@@ -184,7 +188,11 @@ open class SecureStoreModule : Module() { - if (!success) { - throw WriteException("Could not write a null value to SecureStore", key, options.keychainService) - } -- return -+ return SecureStoreAuthType.NONE -+ } -+ -+ if (prefs.contains(keychainAwareKey) && options.failOnDuplicate) { -+ throw WriteException("Key already exists", key, options.keychainService) - } - - try { -@@ -200,8 +208,11 @@ open class SecureStoreModule : Module() { - use in the encrypted JSON item so that we know how to decode and decrypt it when reading - back a value. - */ -- val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) -- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper) -+ val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry( -+ SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication -+ ) -+ val encryptResult = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableCredentialsAlternative) -+ val encryptedItem = encryptResult.value - encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) - saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) - -@@ -209,6 +220,9 @@ open class SecureStoreModule : Module() { - if (prefs.contains(key)) { - prefs.edit().remove(key).apply() - } -+ -+ return encryptResult.authType -+ - } catch (e: KeyPermanentlyInvalidatedException) { - if (!keyIsInvalidated) { - Log.w(TAG, "Key has been invalidated, retrying with the key deleted") -@@ -343,7 +357,11 @@ open class SecureStoreModule : Module() { - return getKeyEntry(keyStoreEntryClass, encryptor, options, requireAuthentication) ?: run { - // Android won't allow us to generate the keys if the device doesn't support biometrics or no biometrics are enrolled - if (requireAuthentication) { -- authenticationHelper.assertBiometricsSupport() -+ if (options.enableCredentialsAlternative) { -+ authenticationHelper.assertDeviceSecurity() -+ } else { -+ authenticationHelper.assertBiometricsSupport() -+ } - } - encryptor.initializeKeyStoreEntry(keyStore, options) - } -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -index 79a600f..ad78439 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt -@@ -1,5 +1,6 @@ - package expo.modules.securestore - -+import androidx.biometric.BiometricPrompt - import expo.modules.kotlin.records.Field - import expo.modules.kotlin.records.Record - import java.io.Serializable -@@ -8,5 +9,39 @@ class SecureStoreOptions( - // Prompt can't be an empty string - @Field var authenticationPrompt: String = " ", - @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, -- @Field var requireAuthentication: Boolean = false -+ @Field var requireAuthentication: Boolean = false, -+ @Field var failOnDuplicate: Boolean = false, -+ @Field var enableCredentialsAlternative: Boolean = false - ) : Record, Serializable -+ -+enum class SecureStoreAuthType(index: Int) { -+ UNKNOWN(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN), -+ CREDENTIAL(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL), -+ BIOMETRIC(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC), -+ -+ /** Prompt failed, no authentication was used at all */ -+ NONE(0) -+} -+ -+data class SecureStoreFeedback( -+ val value: T, -+ val authenticationResult: BiometricPrompt.AuthenticationResult? = null -+) { -+ @Field var authType: SecureStoreAuthType = when (authenticationResult?.authenticationType) { -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN -> { -+ SecureStoreAuthType.UNKNOWN -+ } -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL -> { -+ SecureStoreAuthType.CREDENTIAL -+ } -+ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC -> { -+ SecureStoreAuthType.BIOMETRIC -+ } -+ else -> { -+ SecureStoreAuthType.NONE -+ } -+ } -+ -+ /** Used to return easily convertible values to JS code */ -+ @Field var values: Pair = Pair(value, authType.ordinal) -+} -\ No newline at end of file -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -index 3a12dc9..d5b797b 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt -@@ -1,11 +1,14 @@ - package expo.modules.securestore.encryptors - --import android.annotation.TargetApi -+import android.os.Build - import android.security.keystore.KeyGenParameterSpec - import android.security.keystore.KeyProperties - import android.util.Base64 -+import androidx.annotation.RequiresApi -+import androidx.biometric.BiometricPrompt - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.DecryptException -+import expo.modules.securestore.SecureStoreFeedback - import expo.modules.securestore.SecureStoreModule - import expo.modules.securestore.SecureStoreOptions - import org.json.JSONException -@@ -50,17 +53,24 @@ class AESEncryptor : KeyBasedEncryptor { - return "${getKeyStoreAlias(options)}:$suffix" - } - -- @TargetApi(23) -+ @RequiresApi(Build.VERSION_CODES.R) - @Throws(GeneralSecurityException::class) -- override fun initializeKeyStoreEntry(keyStore: KeyStore, options: SecureStoreOptions): KeyStore.SecretKeyEntry { -+ override fun initializeKeyStoreEntry( -+ keyStore: KeyStore, options: SecureStoreOptions -+ ): KeyStore.SecretKeyEntry { - val extendedKeystoreAlias = getExtendedKeyStoreAlias(options, options.requireAuthentication) - val keyPurposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - -+ val authType = -+ if (options.enableCredentialsAlternative) KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL -+ else KeyProperties.AUTH_BIOMETRIC_STRONG -+ - val algorithmSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(extendedKeystoreAlias, keyPurposes) - .setKeySize(AES_KEY_SIZE_BITS) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setUserAuthenticationRequired(options.requireAuthentication) -+ .setUserAuthenticationParameters(0, authType) - .build() - - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider) -@@ -78,16 +88,25 @@ class AESEncryptor : KeyBasedEncryptor { - keyStoreEntry: KeyStore.SecretKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -- ): JSONObject { -+ authenticationHelper: AuthenticationHelper, -+ enableCredentialsAlternative: Boolean, -+ ): SecureStoreFeedback { - val secretKey = keyStoreEntry.secretKey - val cipher = Cipher.getInstance(AES_CIPHER) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) -- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt) -+ var promptResult: BiometricPrompt.AuthenticationResult? = null -+ val authenticatedCipher: Cipher -+ -+ if (requireAuthentication) { -+ promptResult = authenticationHelper.authenticateCipher(cipher, authenticationPrompt, enableCredentialsAlternative) -+ authenticatedCipher = promptResult.cryptoObject?.cipher ?: cipher -+ } else { -+ authenticatedCipher = cipher -+ } - -- return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) -+ return SecureStoreFeedback(createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec), promptResult) - } - - internal fun createEncryptedItemWithCipher( -@@ -114,7 +133,7 @@ class AESEncryptor : KeyBasedEncryptor { - keyStoreEntry: KeyStore.SecretKeyEntry, - options: SecureStoreOptions, - authenticationHelper: AuthenticationHelper -- ): String { -+ ): SecureStoreFeedback { - val ciphertext = encryptedItem.getString(CIPHERTEXT_PROPERTY) - val ivString = encryptedItem.getString(IV_PROPERTY) - val authenticationTagLength = encryptedItem.getInt(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY) -@@ -128,8 +147,18 @@ class AESEncryptor : KeyBasedEncryptor { - throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) - } - cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) -- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt) -- return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) -+ -+ var promptResult: BiometricPrompt.AuthenticationResult? = null -+ val unlockedCipher: Cipher -+ -+ if (requiresAuthentication) { -+ promptResult = authenticationHelper.authenticateCipher(cipher, options.authenticationPrompt, options.enableCredentialsAlternative) -+ unlockedCipher = promptResult.cryptoObject?.cipher ?: cipher -+ } else { -+ unlockedCipher = cipher -+ } -+ -+ return SecureStoreFeedback(String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8), promptResult) - } - - companion object { -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -index fb42599..0055b62 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt -@@ -7,6 +7,7 @@ import android.util.Base64 - import expo.modules.securestore.AuthenticationHelper - import expo.modules.securestore.EncryptException - import expo.modules.securestore.KeyStoreException -+import expo.modules.securestore.SecureStoreFeedback - import expo.modules.securestore.SecureStoreModule - import expo.modules.securestore.SecureStoreOptions - import org.json.JSONException -@@ -69,8 +70,9 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - keyStoreEntry: KeyStore.PrivateKeyEntry, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -- ): JSONObject { -+ authenticationHelper: AuthenticationHelper, -+ enableCredentialsAlternative: Boolean, -+ ): SecureStoreFeedback { - // This should never be called after we dropped Android SDK 22 support. - throw EncryptException( - "HybridAESEncryption should not be used on Android SDK >= 23. This shouldn't happen. " + -@@ -86,8 +88,8 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto - encryptedItem: JSONObject, - keyStoreEntry: KeyStore.PrivateKeyEntry, - options: SecureStoreOptions, -- authenticationHelper: AuthenticationHelper -- ): String { -+ authenticationHelper: AuthenticationHelper, -+ ): SecureStoreFeedback { - // Decrypt the encrypted symmetric key - val encryptedSecretKeyString = encryptedItem.getString(ENCRYPTED_SECRET_KEY_PROPERTY) - val encryptedSecretKeyBytes = Base64.decode(encryptedSecretKeyString, Base64.DEFAULT) -diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -index e493467..c880ee0 100644 ---- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -+++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt -@@ -1,6 +1,7 @@ - package expo.modules.securestore.encryptors - - import expo.modules.securestore.AuthenticationHelper -+import expo.modules.securestore.SecureStoreFeedback - import expo.modules.securestore.SecureStoreOptions - import org.json.JSONException - import org.json.JSONObject -@@ -25,8 +26,9 @@ interface KeyBasedEncryptor { - keyStoreEntry: E, - requireAuthentication: Boolean, - authenticationPrompt: String, -- authenticationHelper: AuthenticationHelper -- ): JSONObject -+ authenticationHelper: AuthenticationHelper, -+ enableCredentialsAlternative: Boolean, -+ ): SecureStoreFeedback - - @Throws(GeneralSecurityException::class, JSONException::class) - suspend fun decryptItem( -@@ -34,6 +36,6 @@ interface KeyBasedEncryptor { - encryptedItem: JSONObject, - keyStoreEntry: E, - options: SecureStoreOptions, -- authenticationHelper: AuthenticationHelper -- ): String -+ authenticationHelper: AuthenticationHelper, -+ ): SecureStoreFeedback - } -diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts -index d5cd157..ca1cd76 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.d.ts -+++ b/node_modules/expo-secure-store/build/SecureStore.d.ts -@@ -1,4 +1,53 @@ - export type KeychainAccessibilityConstant = number; -+ -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export declare const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5 -+} as const; -+ -+type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; -+ - /** - * The data in the keychain item cannot be accessed after a restart until the device has been - * unlocked once by the user. This may be useful if you need to access the item when the phone -@@ -78,6 +127,45 @@ export type SecureStoreOptions = { - * @platform ios - */ - accessGroup?: string; -+ -+ /** -+ * If value already exists, throw an error -+ * -+ * On Android it throws an error before the prompt for authentication. -+ * On iOS it throws an error after authentication. -+ */ -+ failOnDuplicate?: boolean; -+ /** -+ * On iOS, the system sometimes skips authentication if it has been done recently. -+ * This also applies to actions such as unlocking the screen. -+ * Setting this flag to true will ensure that authentication is always required before accessing the store. -+ * -+ * Use this wisely, when the user did not authenticated recently, setting this to true will lead to prompt -+ * being displayed twice when accessing the store value. -+ * The best use-case for this flag is when testing the behavior on iOS simulator, -+ * since the auth there may not be prompted at all without this flag. -+ * -+ * @warning This flag works only for async version of the save/read SecureStore methods. -+ * @platform ios -+ */ -+ alwaysAskForAuth?: boolean; -+ /** -+ * On iOS, the system does not ask for auth when saving the value to the SecureStore. -+ * The Android however, displays the authentication prompt when saving the value. -+ * To keep the behavior on every platform similar as much as possible, -+ * setting this flag to true will ensure that authentication is required when saving a value to the store. -+ * -+ * @warning This flag works only for async version of the save/read SecureStore methods. -+ * @platform ios -+ */ -+ askForAuthOnSave?: boolean; -+ /** -+ * On Android, there is often no fallback option if biometrics fail. -+ * Therefore, we may want to allow the user to authenticate using credentials if they prefer to. -+ * -+ * @platform android -+ */ -+ enableCredentialsAlternative?: boolean; - }; - /** - * Returns whether the SecureStore API is enabled on the current device. This does not check the app -@@ -109,7 +197,7 @@ export declare function deleteItemAsync(key: string, options?: SecureStoreOption - * > After a key has been invalidated, it becomes impossible to read its value. - * > This only applies to values stored with `requireAuthentication` set to `true`. - */ --export declare function getItemAsync(key: string, options?: SecureStoreOptions): Promise; -+export declare function getItemAsync(key: string, options?: SecureStoreOptions): Promise<[string | null, AuthType]>; - /** - * Stores a key–value pair. - * -@@ -119,7 +207,7 @@ export declare function getItemAsync(key: string, options?: SecureStoreOptions): - * - * @return A promise that rejects if value cannot be stored on the device. - */ --export declare function setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise; -+export declare function setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise; - /** - * Stores a key–value pair synchronously. - * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when the `requireAuthentication` option is set to `true` until the user authenticates. -@@ -129,7 +217,7 @@ export declare function setItemAsync(key: string, value: string, options?: Secur - * @param options An [`SecureStoreOptions`](#securestoreoptions) object. - * - */ --export declare function setItem(key: string, value: string, options?: SecureStoreOptions): void; -+export declare function setItem(key: string, value: string, options?: SecureStoreOptions): AuthType; - /** - * Synchronously reads the stored value associated with the provided key. - * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when reading a value with `requireAuthentication` -@@ -140,7 +228,7 @@ export declare function setItem(key: string, value: string, options?: SecureStor - * @return Previously stored value. It resolves with `null` if there is no entry - * for the given key or if the key has been invalidated. - */ --export declare function getItem(key: string, options?: SecureStoreOptions): string | null; -+export declare function getItem(key: string, options?: SecureStoreOptions): [string | null, AuthType]; - /** - * Checks if the value can be saved with `requireAuthentication` option enabled. - * @return `true` if the device supports biometric authentication and the enrolled method is sufficiently secure. Otherwise, returns `false`. Always returns false on tvOS. -diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js -index 4d87b38..4399e5e 100644 ---- a/node_modules/expo-secure-store/build/SecureStore.js -+++ b/node_modules/expo-secure-store/build/SecureStore.js -@@ -1,6 +1,52 @@ - import ExpoSecureStore from './ExpoSecureStore'; - import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; - // @needsAudit -+ -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5 -+}; - /** - * The data in the keychain item cannot be accessed after a restart until the device has been - * unlocked once by the user. This may be useful if you need to access the item when the phone -@@ -102,7 +148,7 @@ export async function setItemAsync(key, value, options = {}) { - if (!isValidValue(value)) { - throw new Error(`Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`); - } -- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); -+ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); - } - /** - * Stores a key–value pair synchronously. -diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -index 439b08d..98dab3e 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift -@@ -4,6 +4,42 @@ import LocalAuthentication - #endif - import Security - -+@available(iOS 11.2, *) -+public enum AuthType: Int, @unchecked Sendable { -+ /// The device does not support biometry. -+ case none = 0 -+ -+ /// The device supports device credentials -+ case credentials = 1 -+ -+ /// Generic type, not specified whether it was a faceID or touchID -+ case biometrics = 2 -+ -+ /// The device supports Face ID. -+ case faceID = 3 -+ -+ /// The device supports Touch ID. -+ case touchID = 4 -+ -+ /// The device supports Optic ID -+ case opticID = 5 -+} -+ -+struct SecureStoreFeedback { -+ var value: T -+ var authType: Int = AuthType.none.rawValue -+ var values: Array { -+ get { return [value, authType] } -+ } -+} -+ -+struct SecureStoreRuntimeError: LocalizedError { -+ let errorDescription: String? -+ init(_ description: String) { -+ self.errorDescription = description -+ } -+} -+ - public final class SecureStoreModule: Module { - public func definition() -> ModuleDefinition { - Name("ExpoSecureStore") -@@ -18,57 +54,137 @@ public final class SecureStoreModule: Module { - "WHEN_UNLOCKED_THIS_DEVICE_ONLY": SecureStoreAccessible.whenUnlockedThisDeviceOnly.rawValue - ]) - -- AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in -- return try get(with: key, options: options) -+ AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -+ if options.requireAuthentication && options.alwaysAskForAuth { -+ try await askForAuth( -+ prompt: options.authenticationPrompt -+ ?? "Authentication required" -+ ) -+ } -+ return getSecureStoreFeedback(value: try get(with: key, options: options)).values - } - -- Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in -- return try get(with: key, options: options) -+ Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) in -+ return getSecureStoreFeedback(value: try get(with: key, options: options)).values - } - -- AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in -+ AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Int in - guard let key = validate(for: key) else { - throw InvalidKeyException() - } - -- return try set(value: value, with: key, options: options) -+ if options.requireAuthentication && ( -+ options.alwaysAskForAuth || options.askForAuthOnSave -+ ) { -+ try await askForAuth( -+ prompt: options.authenticationPrompt -+ ?? "Authentication required" -+ ) -+ } -+ -+ let result = try set(value: value, with: key, options: options) -+ -+ if !result { -+ return AuthType.none.rawValue -+ } -+ -+ return getSecureStoreFeedback(value: true).authType - } - -- Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in -+ Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Int in - guard let key = validate(for: key) else { - throw InvalidKeyException() - } - -- return try set(value: value, with: key, options: options) -+ let result = try set(value: value, with: key, options: options) -+ -+ if !result { -+ return AuthType.none.rawValue -+ } -+ -+ return getSecureStoreFeedback(value: true).authType - } - - AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in -- let noAuthSearchDictionary = query(with: key, options: options, requireAuthentication: false) -- let authSearchDictionary = query(with: key, options: options, requireAuthentication: true) -- let legacySearchDictionary = query(with: key, options: options) -+ let noAuthSearchDictionary = try query(with: key, options: options, requireAuthentication: false) -+ let authSearchDictionary = try query(with: key, options: options, requireAuthentication: true) -+ let legacySearchDictionary = try query(with: key, options: options) - - SecItemDelete(legacySearchDictionary as CFDictionary) - SecItemDelete(authSearchDictionary as CFDictionary) - SecItemDelete(noAuthSearchDictionary as CFDictionary) - } - -- Function("canUseBiometricAuthentication") {() -> Bool in -- #if os(tvOS) -- return false -- #else -- let context = LAContext() -- var error: NSError? -- let isBiometricsSupported: Bool = context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error) -+ Function("canUseBiometricAuthentication") { () -> Bool in -+ return areBiometricsEnabled() -+ } -+ } - -- if error != nil { -- return false -- } -- return isBiometricsSupported -- #endif -+ private func askForAuth(prompt: String) async throws { -+ let context = LAContext() -+ context.localizedReason = prompt -+ -+ guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) else { -+ throw SecureStoreRuntimeError("No authentication method available") - } -+ -+ let result = try await context.evaluatePolicy( -+ LAPolicy.deviceOwnerAuthentication, -+ localizedReason: context.localizedReason -+ ) -+ -+ if !result { -+ throw SecureStoreRuntimeError("Unable to authenticate") -+ } -+ } -+ -+ private func getAuthType() -> AuthType { -+ if !areBiometricsEnabled() {return AuthType.credentials} -+ let biometryType = LAContext().biometryType -+ -+ switch biometryType { -+ case .faceID: return .faceID -+ case .touchID: return .touchID -+ case .opticID: return .opticID // available since iOS 17 -+ case .none: fallthrough // this one continues to the next line -+ @unknown default: return .credentials -+ } -+ } -+ -+ private func getSecureStoreFeedback(value: T) -> SecureStoreFeedback { -+ return SecureStoreFeedback(value: value, authType: getAuthType().rawValue) - } - -- private func get(with key: String, options: SecureStoreOptions) throws -> String? { -+ private func getAccessControl(accessibility: CFString) throws -> SecAccessControl -+ { -+ guard -+ Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String -+ != nil -+ else { -+ throw MissingPlistKeyException() -+ } -+ -+ var error: Unmanaged? = nil -+ guard -+ let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, .userPresence, &error) -+ else { -+ let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } -+ throw SecAccessControlError(errorCode) -+ } -+ -+ return accessOptions -+ } -+ -+ private func areBiometricsEnabled() -> Bool { -+ #if os(tvOS) -+ return false -+ #else -+ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: nil) -+ #endif -+ } -+ -+ private func get(with key: String, options: SecureStoreOptions) throws -> String? -+ { - guard let key = validate(for: key) else { - throw InvalidKeyException() - } -@@ -89,7 +205,7 @@ public final class SecureStoreModule: Module { - } - - private func set(value: String, with key: String, options: SecureStoreOptions) throws -> Bool { -- var setItemQuery = query(with: key, options: options, requireAuthentication: options.requireAuthentication) -+ var setItemQuery = try query(with: key, options: options, requireAuthentication: options.requireAuthentication) - - let valueData = value.data(using: .utf8) - setItemQuery[kSecValueData as String] = valueData -@@ -98,17 +214,6 @@ public final class SecureStoreModule: Module { - - if !options.requireAuthentication { - setItemQuery[kSecAttrAccessible as String] = accessibility -- } else { -- guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { -- throw MissingPlistKeyException() -- } -- -- var error: Unmanaged? = nil -- guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, .biometryCurrentSet, &error) else { -- let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } -- throw SecAccessControlError(errorCode) -- } -- setItemQuery[kSecAttrAccessControl as String] = accessOptions - } - - let status = SecItemAdd(setItemQuery as CFDictionary, nil) -@@ -116,10 +221,13 @@ public final class SecureStoreModule: Module { - switch status { - case errSecSuccess: - // On success we want to remove the other key alias and legacy key (if they exist) to avoid conflicts during reads -- SecItemDelete(query(with: key, options: options) as CFDictionary) -- SecItemDelete(query(with: key, options: options, requireAuthentication: !options.requireAuthentication) as CFDictionary) -+ SecItemDelete(try query(with: key, options: options) as CFDictionary) -+ SecItemDelete(try query(with: key, options: options,requireAuthentication: !options.requireAuthentication) as CFDictionary) - return true - case errSecDuplicateItem: -+ if options.failOnDuplicate { -+ throw SecureStoreRuntimeError("Key already exists") -+ } - return try update(value: value, with: key, options: options) - default: - throw KeyChainException(status) -@@ -127,15 +235,11 @@ public final class SecureStoreModule: Module { - } - - private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool { -- var query = query(with: key, options: options, requireAuthentication: options.requireAuthentication) -+ let query = try query(with: key, options: options, requireAuthentication: options.requireAuthentication) - - let valueData = value.data(using: .utf8) - let updateDictionary = [kSecValueData as String: valueData] - -- if let authPrompt = options.authenticationPrompt { -- query[kSecUseOperationPrompt as String] = authPrompt -- } -- - let status = SecItemUpdate(query as CFDictionary, updateDictionary as CFDictionary) - - if status == errSecSuccess { -@@ -146,15 +250,11 @@ public final class SecureStoreModule: Module { - } - - private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? { -- var query = query(with: key, options: options, requireAuthentication: requireAuthentication) -+ var query = try query(with: key, options: options, requireAuthentication: requireAuthentication) - - query[kSecMatchLimit as String] = kSecMatchLimitOne - query[kSecReturnData as String] = kCFBooleanTrue - -- if let authPrompt = options.authenticationPrompt { -- query[kSecUseOperationPrompt as String] = authPrompt -- } -- - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - -@@ -171,21 +271,28 @@ public final class SecureStoreModule: Module { - } - } - -- private func query(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) -> [String: Any] { -+ private func query(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> [String: Any] { - var service = options.keychainService ?? "app" - if let requireAuthentication { - service.append(":\(requireAuthentication ? "auth" : "no-auth")") - } - - let encodedKey = Data(key.utf8) -+ let accessibility = attributeWith(options: options) - - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrGeneric as String: encodedKey, -- kSecAttrAccount as String: encodedKey -+ kSecAttrAccount as String: encodedKey, - ] - -+ if options.requireAuthentication { -+ query[kSecAttrAccessControl as String] = try getAccessControl( -+ accessibility: accessibility -+ ) -+ } -+ - if let accessGroup = options.accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup - } -diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -index 7e3fa4d..481b1fe 100644 ---- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -+++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift -@@ -15,4 +15,13 @@ internal struct SecureStoreOptions: Record { - - @Field - var accessGroup: String? -+ -+ @Field -+ var failOnDuplicate: Bool = false -+ -+ @Field -+ var alwaysAskForAuth: Bool = false -+ -+ @Field -+ var askForAuthOnSave: Bool = false - } -diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts -index ee43e04..ccfd2a9 100644 ---- a/node_modules/expo-secure-store/src/SecureStore.ts -+++ b/node_modules/expo-secure-store/src/SecureStore.ts -@@ -3,6 +3,54 @@ import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; - - export type KeychainAccessibilityConstant = number; - -+/** -+ * Authentication type returned by the SecureStore after reading item or saving it to the store. -+ */ -+export const AUTH_TYPE = { -+ /** -+ * This is purely for backwards compatibility. -+ * Although it is not listed as a return value of the getAuthenticationType() method, -+ * it is still present in the Android code. -+ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() -+ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() -+ * @platform android -+ */ -+ UNKNOWN: -1, -+ /** -+ * Returned when the authentication fails -+ * @platform android -+ * @platform ios -+ */ -+ NONE: 0, -+ /** -+ * Generic type, not specified whether it was a passcode or pattern. -+ * @platform android -+ * @platform ios -+ */ -+ CREDENTIALS: 1, -+ /** -+ * Generic type, not specified whether it was a face scan or a fingerprint -+ * @platform android -+ */ -+ BIOMETRICS: 2, -+ /** -+ * FaceID was used to authenticate -+ * @platform ios -+ */ -+ FACE_ID: 3, -+ /** -+ * TouchID was used to authenticate -+ * @platform ios -+ */ -+ TOUCH_ID: 4, -+ /** -+ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) -+ */ -+ OPTIC_ID: 5 -+} as const; -+ -+type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; -+ - // @needsAudit - /** - * The data in the keychain item cannot be accessed after a restart until the device has been -@@ -102,6 +150,45 @@ export type SecureStoreOptions = { - * @platform ios - */ - accessGroup?: string; -+ -+ /** -+ * If value already exists, throw an error -+ * -+ * On Android it throws an error before the prompt for authentication. -+ * On iOS it throws an error after authentication. -+ */ -+ failOnDuplicate?: boolean; -+ /** -+ * On iOS, the system sometimes skips authentication if it has been done recently. -+ * This also applies to actions such as unlocking the screen. -+ * Setting this flag to true will ensure that authentication is always required before accessing the store. -+ * -+ * Use this wisely, when the user did not authenticated recently, setting this to true will lead to prompt -+ * being displayed twice when accessing the store value. -+ * The best use-case for this flag is when testing the behavior on iOS simulator, -+ * since the auth there may not be prompted at all without this flag. -+ * -+ * @warning This flag works only for async version of the save/read SecureStore methods. -+ * @platform ios -+ */ -+ alwaysAskForAuth?: boolean; -+ /** -+ * On iOS, the system does not ask for auth when saving the value to the SecureStore. -+ * The Android however, displays the authentication prompt when saving the value. -+ * To keep the behavior on every platform similar as much as possible, -+ * setting this flag to true will ensure that authentication is required when saving a value to the store. -+ * -+ * @warning This flag works only for async version of the save/read SecureStore methods. -+ * @platform ios -+ */ -+ askForAuthOnSave?: boolean; -+ /** -+ * On Android, there is often no fallback option if biometrics fail. -+ * Therefore, we may want to allow the user to authenticate using credentials if they prefer to. -+ * -+ * @platform android -+ */ -+ enableCredentialsAlternative?: boolean; - }; - - // @needsAudit -@@ -151,7 +238,7 @@ export async function deleteItemAsync( - export async function getItemAsync( - key: string, - options: SecureStoreOptions = {} --): Promise { -+): Promise<[string | null, AuthType]> { - ensureValidKey(key); - return await ExpoSecureStore.getValueWithKeyAsync(key, options); - } -@@ -170,15 +257,15 @@ export async function setItemAsync( - key: string, - value: string, - options: SecureStoreOptions = {} --): Promise { -+): Promise { - ensureValidKey(key); - if (!isValidValue(value)) { - throw new Error( -- `Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.` -+ `Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`, - ); - } - -- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); -+ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); - } - - /** -@@ -190,7 +277,7 @@ export async function setItemAsync( - * @param options An [`SecureStoreOptions`](#securestoreoptions) object. - * - */ --export function setItem(key: string, value: string, options: SecureStoreOptions = {}): void { -+export function setItem(key: string, value: string, options: SecureStoreOptions = {}): AuthType { - ensureValidKey(key); - if (!isValidValue(value)) { - throw new Error( -@@ -211,7 +298,7 @@ export function setItem(key: string, value: string, options: SecureStoreOptions - * @return Previously stored value. It resolves with `null` if there is no entry - * for the given key or if the key has been invalidated. - */ --export function getItem(key: string, options: SecureStoreOptions = {}): string | null { -+export function getItem(key: string, options: SecureStoreOptions = {}): [string | null, AuthType] { - ensureValidKey(key); - return ExpoSecureStore.getValueWithKeySync(key, options); - } diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch new file mode 100644 index 000000000000..d5d5edad1a25 --- /dev/null +++ b/patches/expo-secure-store/expo-secure-store+14.2.4+001+enable-device-fallback.patch @@ -0,0 +1,435 @@ +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt +index a281b88..4a1a009 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt +@@ -2,6 +2,7 @@ package expo.modules.securestore + + import android.annotation.SuppressLint + import android.app.Activity ++import android.app.KeyguardManager + import android.content.Context + import android.os.Build + import androidx.biometric.BiometricManager +@@ -19,9 +20,9 @@ class AuthenticationHelper( + ) { + private var isAuthenticating = false + +- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String): Cipher { ++ suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String, enableDeviceFallback: Boolean): Cipher { + if (requiresAuthentication) { +- return openAuthenticationPrompt(cipher, title).cryptoObject?.cipher ++ return openAuthenticationPrompt(cipher, title, enableDeviceFallback).cryptoObject?.cipher + ?: throw AuthenticationException("Couldn't get cipher from authentication result") + } + return cipher +@@ -29,7 +30,8 @@ class AuthenticationHelper( + + private suspend fun openAuthenticationPrompt( + cipher: Cipher, +- title: String ++ title: String, ++ enableDeviceFallback: Boolean + ): BiometricPrompt.AuthenticationResult { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + throw AuthenticationException("Biometric authentication requires Android API 23") +@@ -41,11 +43,16 @@ class AuthenticationHelper( + isAuthenticating = true + + try { +- assertBiometricsSupport() ++ if (enableDeviceFallback) { ++ assertDeviceSecurity() ++ } else { ++ assertBiometricsSupport() ++ } ++ + val fragmentActivity = getCurrentActivity() as? FragmentActivity + ?: throw AuthenticationException("Cannot display biometric prompt when the app is not in the foreground") + +- val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title) ++ val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title, enableDeviceFallback) + + return withContext(Dispatchers.Main.immediate) { + return@withContext authenticationPrompt.authenticate(cipher) +@@ -56,6 +63,14 @@ class AuthenticationHelper( + } + } + ++ fun assertDeviceSecurity() { ++ val manager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager ++ val isSecure = manager.isDeviceSecure ++ if (!isSecure) { ++ throw AuthenticationException("No authentication method available") ++ } ++ } ++ + fun assertBiometricsSupport() { + val biometricManager = BiometricManager.from(context) + @SuppressLint("SwitchIntDef") // BiometricManager.BIOMETRIC_SUCCESS shouldn't do anything +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt +index e5729cc..ce448d6 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt +@@ -1,5 +1,7 @@ + package expo.modules.securestore + ++import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG ++import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL + import android.content.Context + import androidx.biometric.BiometricPrompt + import androidx.biometric.BiometricPrompt.PromptInfo +@@ -11,12 +13,23 @@ import kotlin.coroutines.resume + import kotlin.coroutines.resumeWithException + import kotlin.coroutines.suspendCoroutine + +-class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) { ++class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String, enableDeviceFallback: Boolean) { ++ private var authType: Int = if (enableDeviceFallback) BIOMETRIC_STRONG or DEVICE_CREDENTIAL else BIOMETRIC_STRONG + private var executor: Executor = ContextCompat.getMainExecutor(context) +- private var promptInfo = PromptInfo.Builder() +- .setTitle(title) +- .setNegativeButtonText(context.getString(android.R.string.cancel)) +- .build() ++ private var promptInfo = buildPromptInfo(context, title, enableDeviceFallback) ++ ++ private fun buildPromptInfo(context: Context, title: String, enableDeviceFallback: Boolean): PromptInfo { ++ var prompt = PromptInfo.Builder() ++ .setTitle(title) ++ .setAllowedAuthenticators(authType) ++ ++ if (!enableDeviceFallback) { ++ prompt = prompt. ++ setNegativeButtonText(context.getString(android.R.string.cancel)) ++ } ++ ++ return prompt.build() ++ } + + suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? = + suspendCoroutine { continuation -> +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +index 0fef884..bf9689b 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +@@ -75,6 +75,15 @@ open class SecureStoreModule : Module() { + } + } + ++ Function("canUseDeviceCredentialsAuthentication") { ++ return@Function try { ++ authenticationHelper.assertDeviceSecurity() ++ true ++ } catch (e: AuthenticationException) { ++ false ++ } ++ } ++ + OnCreate { + authenticationHelper = AuthenticationHelper(reactContext, appContext.legacyModuleRegistry) + hybridAESEncryptor = HybridAESEncryptor(reactContext, mAESEncryptor) +@@ -201,7 +210,7 @@ open class SecureStoreModule : Module() { + back a value. + */ + val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) +- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper) ++ val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) + encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) + saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) + +@@ -343,7 +352,11 @@ open class SecureStoreModule : Module() { + return getKeyEntry(keyStoreEntryClass, encryptor, options, requireAuthentication) ?: run { + // Android won't allow us to generate the keys if the device doesn't support biometrics or no biometrics are enrolled + if (requireAuthentication) { +- authenticationHelper.assertBiometricsSupport() ++ if (options.enableDeviceFallback) { ++ authenticationHelper.assertDeviceSecurity() ++ } else { ++ authenticationHelper.assertBiometricsSupport() ++ } + } + encryptor.initializeKeyStoreEntry(keyStore, options) + } +@@ -383,6 +396,7 @@ open class SecureStoreModule : Module() { + private const val KEYSTORE_ALIAS_PROPERTY = "keystoreAlias" + const val USES_KEYSTORE_SUFFIX_PROPERTY = "usesKeystoreSuffix" + const val DEFAULT_KEYSTORE_ALIAS = "key_v1" ++ const val DEFAULT_FALLBACK_KEYSTORE_ALIAS = "fallback_key_v1" + const val AUTHENTICATED_KEYSTORE_SUFFIX = "keystoreAuthenticated" + const val UNAUTHENTICATED_KEYSTORE_SUFFIX = "keystoreUnauthenticated" + } +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +index 79a600f..0455fd6 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +@@ -8,5 +8,6 @@ class SecureStoreOptions( + // Prompt can't be an empty string + @Field var authenticationPrompt: String = " ", + @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, +- @Field var requireAuthentication: Boolean = false ++ @Field var requireAuthentication: Boolean = false, ++ @Field var enableDeviceFallback: Boolean = false + ) : Record, Serializable +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt +index 3a12dc9..1cec6c0 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt +@@ -1,9 +1,10 @@ + package expo.modules.securestore.encryptors + +-import android.annotation.TargetApi ++import android.os.Build + import android.security.keystore.KeyGenParameterSpec + import android.security.keystore.KeyProperties + import android.util.Base64 ++import androidx.annotation.RequiresApi + import expo.modules.securestore.AuthenticationHelper + import expo.modules.securestore.DecryptException + import expo.modules.securestore.SecureStoreModule +@@ -33,6 +34,11 @@ import javax.crypto.spec.GCMParameterSpec + class AESEncryptor : KeyBasedEncryptor { + override fun getKeyStoreAlias(options: SecureStoreOptions): String { + val baseAlias = options.keychainService ++ ++ if (baseAlias == SecureStoreModule.DEFAULT_KEYSTORE_ALIAS && options.enableDeviceFallback) { ++ return "$AES_CIPHER:${SecureStoreModule.DEFAULT_FALLBACK_KEYSTORE_ALIAS}" ++ } ++ + return "$AES_CIPHER:$baseAlias" + } + +@@ -50,17 +56,22 @@ class AESEncryptor : KeyBasedEncryptor { + return "${getKeyStoreAlias(options)}:$suffix" + } + +- @TargetApi(23) ++ @RequiresApi(Build.VERSION_CODES.R) + @Throws(GeneralSecurityException::class) + override fun initializeKeyStoreEntry(keyStore: KeyStore, options: SecureStoreOptions): KeyStore.SecretKeyEntry { + val extendedKeystoreAlias = getExtendedKeyStoreAlias(options, options.requireAuthentication) + val keyPurposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ++ val authType = ++ if (options.enableDeviceFallback) KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL ++ else KeyProperties.AUTH_BIOMETRIC_STRONG ++ + val algorithmSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(extendedKeystoreAlias, keyPurposes) + .setKeySize(AES_KEY_SIZE_BITS) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(options.requireAuthentication) ++ .setUserAuthenticationParameters(0, authType) + .build() + + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider) +@@ -78,14 +89,15 @@ class AESEncryptor : KeyBasedEncryptor { + keyStoreEntry: KeyStore.SecretKeyEntry, + requireAuthentication: Boolean, + authenticationPrompt: String, +- authenticationHelper: AuthenticationHelper ++ authenticationHelper: AuthenticationHelper, ++ enableDeviceFallback: Boolean, + ): JSONObject { + val secretKey = keyStoreEntry.secretKey + val cipher = Cipher.getInstance(AES_CIPHER) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) +- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt) ++ val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt, enableDeviceFallback) + + return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) + } +@@ -128,7 +140,7 @@ class AESEncryptor : KeyBasedEncryptor { + throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) + } + cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) +- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt) ++ val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt, options.enableDeviceFallback) + return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) + } + +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt +index fb42599..e996b39 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt +@@ -51,6 +51,11 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto + + override fun getKeyStoreAlias(options: SecureStoreOptions): String { + val baseAlias = options.keychainService ++ ++ if (baseAlias == SecureStoreModule.DEFAULT_KEYSTORE_ALIAS && options.enableDeviceFallback) { ++ return "$RSA_CIPHER:${SecureStoreModule.DEFAULT_FALLBACK_KEYSTORE_ALIAS}" ++ } ++ + return "$RSA_CIPHER:$baseAlias" + } + +@@ -69,7 +74,8 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto + keyStoreEntry: KeyStore.PrivateKeyEntry, + requireAuthentication: Boolean, + authenticationPrompt: String, +- authenticationHelper: AuthenticationHelper ++ authenticationHelper: AuthenticationHelper, ++ enableDeviceFallback: Boolean, + ): JSONObject { + // This should never be called after we dropped Android SDK 22 support. + throw EncryptException( +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt +index e493467..39459ff 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt +@@ -25,7 +25,8 @@ interface KeyBasedEncryptor { + keyStoreEntry: E, + requireAuthentication: Boolean, + authenticationPrompt: String, +- authenticationHelper: AuthenticationHelper ++ authenticationHelper: AuthenticationHelper, ++ enableDeviceFallback: Boolean, + ): JSONObject + + @Throws(GeneralSecurityException::class, JSONException::class) +diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts +index d5cd157..835a4d4 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.d.ts ++++ b/node_modules/expo-secure-store/build/SecureStore.d.ts +@@ -78,6 +78,16 @@ export type SecureStoreOptions = { + * @platform ios + */ + accessGroup?: string; ++ /** ++ * This flag enables users to authenticate using Lock Screen Knowledge Factor (e.g. PIN, pattern or password). ++ * For sensitive apps, it is recommended not having biometric fall back to such factor. ++ * @see: https://developer.android.com/security/fraud-prevention/authentication ++ * ++ * @default false ++ * @platform android ++ * @platform ios ++ */ ++ enableDeviceFallback?: boolean; + }; + /** + * Returns whether the SecureStore API is enabled on the current device. This does not check the app +@@ -148,4 +158,11 @@ export declare function getItem(key: string, options?: SecureStoreOptions): stri + * @platform ios + */ + export declare function canUseBiometricAuthentication(): boolean; ++/** ++ * Checks whether any device credentials are configured on the device. ++ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. ++ * @platform android ++ * @platform ios ++ */ ++export declare function canUseDeviceCredentialsAuthentication(): boolean; + //# sourceMappingURL=SecureStore.d.ts.map +\ No newline at end of file +diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js +index 4d87b38..e11bd94 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.js ++++ b/node_modules/expo-secure-store/build/SecureStore.js +@@ -143,6 +143,15 @@ export function getItem(key, options = {}) { + export function canUseBiometricAuthentication() { + return ExpoSecureStore.canUseBiometricAuthentication(); + } ++/** ++ * Checks whether any device credentials are configured on the device. ++ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. ++ * @platform android ++ * @platform ios ++ */ ++export function canUseDeviceCredentialsAuthentication() { ++ return ExpoSecureStore.canUseDeviceCredentialsAuthentication(); ++} + function ensureValidKey(key) { + if (!isValidKey(key)) { + throw new Error(`Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".`); +diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +index 439b08d..1268979 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +@@ -66,6 +66,14 @@ public final class SecureStoreModule: Module { + return isBiometricsSupported + #endif + } ++ ++ Function("canUseDeviceCredentialsAuthentication") { () -> Bool in ++ return areDeviceCredentialsEnabled() ++ } ++ } ++ ++ private func areDeviceCredentialsEnabled() -> Bool { ++ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthentication, error: nil) + } + + private func get(with key: String, options: SecureStoreOptions) throws -> String? { +@@ -99,12 +107,17 @@ public final class SecureStoreModule: Module { + if !options.requireAuthentication { + setItemQuery[kSecAttrAccessible as String] = accessibility + } else { +- guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { +- throw MissingPlistKeyException() ++ if (!options.enableDeviceFallback) { ++ guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else { ++ throw MissingPlistKeyException() ++ } + } + + var error: Unmanaged? = nil +- guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, .biometryCurrentSet, &error) else { ++ ++ let accessControlFlag: SecAccessControlCreateFlags = options.enableDeviceFallback ? .userPresence : .biometryCurrentSet ++ ++ guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, accessControlFlag, &error) else { + let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } + throw SecAccessControlError(errorCode) + } +diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +index 7e3fa4d..c9e843b 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +@@ -15,4 +15,7 @@ internal struct SecureStoreOptions: Record { + + @Field + var accessGroup: String? ++ ++ @Field ++ var enableDeviceFallback: Bool = false + } +diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts +index ee43e04..4f253a1 100644 +--- a/node_modules/expo-secure-store/src/SecureStore.ts ++++ b/node_modules/expo-secure-store/src/SecureStore.ts +@@ -102,6 +102,17 @@ export type SecureStoreOptions = { + * @platform ios + */ + accessGroup?: string; ++ ++ /** ++ * This flag enables users to authenticate using Lock Screen Knowledge Factor (e.g. PIN, pattern or password). ++ * For sensitive apps, it is recommended not having biometric fall back to such factor. ++ * @see: https://developer.android.com/security/fraud-prevention/authentication ++ * ++ * @default false ++ * @platform android ++ * @platform ios ++ */ ++ enableDeviceFallback?: boolean; + }; + + // @needsAudit +@@ -226,6 +237,16 @@ export function canUseBiometricAuthentication(): boolean { + return ExpoSecureStore.canUseBiometricAuthentication(); + } + ++/** ++ * Checks whether any device credentials are configured on the device. ++ * @return `true` if the device has device credentials configured. Otherwise, returns `false`. ++ * @platform android ++ * @platform ios ++ */ ++export function canUseDeviceCredentialsAuthentication(): boolean { ++ return ExpoSecureStore.canUseDeviceCredentialsAuthentication(); ++} ++ + function ensureValidKey(key: string) { + if (!isValidKey(key)) { + throw new Error( diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch new file mode 100644 index 000000000000..c8cdfcf5ac02 --- /dev/null +++ b/patches/expo-secure-store/expo-secure-store+14.2.4+002+return-auth-type.patch @@ -0,0 +1,901 @@ +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt +index 4a1a009..980ef0a 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt +@@ -20,12 +20,12 @@ class AuthenticationHelper( + ) { + private var isAuthenticating = false + +- suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String, enableDeviceFallback: Boolean): Cipher { +- if (requiresAuthentication) { +- return openAuthenticationPrompt(cipher, title, enableDeviceFallback).cryptoObject?.cipher +- ?: throw AuthenticationException("Couldn't get cipher from authentication result") ++ suspend fun authenticateCipher(cipher: Cipher, title: String, enableDeviceFallback: Boolean): BiometricPrompt.AuthenticationResult { ++ val promptResult = openAuthenticationPrompt(cipher, title, enableDeviceFallback) ++ if (promptResult.cryptoObject?.cipher == null) { ++ throw AuthenticationException("Couldn't get cipher from authentication result") + } +- return cipher ++ return promptResult + } + + private suspend fun openAuthenticationPrompt( +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +index 3f21552..d209efc 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +@@ -36,23 +36,23 @@ open class SecureStoreModule : Module() { + + AsyncFunction("setValueWithKeyAsync") Coroutine { value: String?, key: String?, options: SecureStoreOptions -> + key ?: throw NullKeyException() +- return@Coroutine setItemImpl(key, value, options, false) ++ return@Coroutine narrowSecureStoreFeedback(SecureStoreFeedbackAction.SET, setItemImpl(key, value, options, false), options).value + } + + AsyncFunction("getValueWithKeyAsync") Coroutine { key: String, options: SecureStoreOptions -> +- return@Coroutine getItemImpl(key, options) ++ return@Coroutine narrowSecureStoreFeedback(SecureStoreFeedbackAction.GET,getItemImpl(key, options), options).value + } + + Function("setValueWithKeySync") { value: String?, key: String?, options: SecureStoreOptions -> + key ?: throw NullKeyException() + return@Function runBlocking { +- return@runBlocking setItemImpl(key, value, options, keyIsInvalidated = false) ++ return@runBlocking narrowSecureStoreFeedback(SecureStoreFeedbackAction.SET, setItemImpl(key, value, options, keyIsInvalidated = false), options).value + } + } + + Function("getValueWithKeySync") { key: String, options: SecureStoreOptions -> + return@Function runBlocking { +- return@runBlocking getItemImpl(key, options) ++ return@runBlocking narrowSecureStoreFeedback(SecureStoreFeedbackAction.GET, getItemImpl(key, options), options).value + } + } + +@@ -94,7 +94,23 @@ open class SecureStoreModule : Module() { + } + } + +- private suspend fun getItemImpl(key: String, options: SecureStoreOptions): String? { ++ private suspend fun narrowSecureStoreFeedback(action: String, feedback: SecureStoreOriginalFeedback, options: SecureStoreOptions): SecureStoreNarrowedFeedback { ++ if (!options.returnUsedAuthenticationType) { ++ return feedback ++ } ++ ++ if (action == SecureStoreFeedbackAction.GET) { ++ return SecureStoreGetFeedback(feedback.source, feedback.authenticationResult) ++ } ++ ++ if (action == SecureStoreFeedbackAction.SET) { ++ return SecureStoreSetFeedback(feedback.source, feedback.authenticationResult) ++ } ++ ++ return feedback ++ } ++ ++ private suspend fun getItemImpl(key: String, options: SecureStoreOptions): SecureStoreOriginalFeedback { + // We use a SecureStore-specific shared preferences file, which lets us do things like enumerate + // its entries or clear all of them + val prefs: SharedPreferences = getSharedPreferences() +@@ -104,10 +120,10 @@ open class SecureStoreModule : Module() { + } else if (prefs.contains(key)) { // For backwards-compatibility try to read using the old key format + return readJSONEncodedItem(key, prefs, options) + } +- return null ++ return SecureStoreOriginalFeedback(null) + } + +- private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): String? { ++ private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): SecureStoreOriginalFeedback { + val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) + + val legacyEncryptedItemString = prefs.getString(key, null) +@@ -126,7 +142,7 @@ open class SecureStoreModule : Module() { + "" + } + +- encryptedItemString ?: return null ++ encryptedItemString ?: return SecureStoreOriginalFeedback(null) + + val encryptedItem: JSONObject = try { + JSONObject(encryptedItemString) +@@ -149,13 +165,13 @@ open class SecureStoreModule : Module() { + "This situation occurs when the app is reinstalled. The value will be removed to avoid future errors. Returning null" + ) + deleteItemImpl(key, options) +- return null ++ return SecureStoreOriginalFeedback(null) + } + return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper) + } + HybridAESEncryptor.NAME -> { + val privateKeyEntry = getKeyEntryCompat(PrivateKeyEntry::class.java, hybridAESEncryptor, options, requireAuthentication, usesKeystoreSuffix) +- ?: return null ++ ?: return SecureStoreOriginalFeedback(null) + return hybridAESEncryptor.decryptItem(key, encryptedItem, privateKeyEntry, options, authenticationHelper) + } + else -> { +@@ -164,7 +180,7 @@ open class SecureStoreModule : Module() { + } + } catch (e: KeyPermanentlyInvalidatedException) { + Log.w(TAG, "The requested key has been permanently invalidated. Returning null") +- return null ++ return SecureStoreOriginalFeedback(null) + } catch (e: BadPaddingException) { + // The key from the KeyStore is unable to decode the entry. This is because a new key was generated, but the entries are encrypted using the old one. + // This usually means that the user has reinstalled the app. We can safely remove the old value and return null as it's impossible to decrypt it. +@@ -174,7 +190,7 @@ open class SecureStoreModule : Module() { + "The entry in shared preferences is out of sync with the keystore. It will be removed, returning null." + ) + deleteItemImpl(key, options) +- return null ++ return SecureStoreOriginalFeedback(null) + } catch (e: GeneralSecurityException) { + throw (DecryptException(e.message, key, options.keychainService, e)) + } catch (e: CodedException) { +@@ -184,7 +200,7 @@ open class SecureStoreModule : Module() { + } + } + +- private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean) { ++ private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean): SecureStoreOriginalFeedback { + val keychainAwareKey = createKeychainAwareKey(key, options.keychainService) + val prefs: SharedPreferences = getSharedPreferences() + +@@ -193,7 +209,7 @@ open class SecureStoreModule : Module() { + if (!success) { + throw WriteException("Could not write a null value to SecureStore", key, options.keychainService) + } +- return ++ return SecureStoreOriginalFeedback(null) + } + + try { +@@ -210,7 +226,8 @@ open class SecureStoreModule : Module() { + back a value. + */ + val secretKeyEntry: SecretKeyEntry = getOrCreateKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, options.requireAuthentication) +- val encryptedItem = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) ++ val encryptResult = mAESEncryptor.createEncryptedItem(value, secretKeyEntry, options.requireAuthentication, options.authenticationPrompt, authenticationHelper, options.enableDeviceFallback) ++ val encryptedItem = encryptResult.value + encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME) + saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService) + +@@ -218,6 +235,9 @@ open class SecureStoreModule : Module() { + if (prefs.contains(key)) { + prefs.edit().remove(key).apply() + } ++ ++ return encryptResult ++ + } catch (e: KeyPermanentlyInvalidatedException) { + if (!keyIsInvalidated) { + Log.w(TAG, "Key has been invalidated, retrying with the key deleted") +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +index 0455fd6..4e30440 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +@@ -1,5 +1,6 @@ + package expo.modules.securestore + ++import androidx.biometric.BiometricPrompt + import expo.modules.kotlin.records.Field + import expo.modules.kotlin.records.Record + import java.io.Serializable +@@ -9,5 +10,58 @@ class SecureStoreOptions( + @Field var authenticationPrompt: String = " ", + @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, + @Field var requireAuthentication: Boolean = false, +- @Field var enableDeviceFallback: Boolean = false ++ @Field var enableDeviceFallback: Boolean = false, ++ @Field var returnUsedAuthenticationType: Boolean = false + ) : Record, Serializable ++ ++enum class SecureStoreAuthType(index: Int) { ++ UNKNOWN(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN), ++ CREDENTIAL(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL), ++ BIOMETRIC(BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC), ++ ++ /** Prompt failed, no authentication was used at all */ ++ NONE(0) ++} ++ ++open class SecureStoreFeedbackAction { ++ companion object { ++ const val GET = "GET" ++ const val SET = "SET" ++ } ++} ++ ++abstract class SecureStoreFeedback( ++ val source: T, ++ val authenticationResult: BiometricPrompt.AuthenticationResult? ++) { ++ @Field var authType: SecureStoreAuthType = when (authenticationResult?.authenticationType) { ++ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN -> { ++ SecureStoreAuthType.UNKNOWN ++ } ++ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL -> { ++ SecureStoreAuthType.CREDENTIAL ++ } ++ BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC -> { ++ SecureStoreAuthType.BIOMETRIC ++ } ++ else -> { ++ SecureStoreAuthType.NONE ++ } ++ } ++ abstract val value: R ++} ++ ++class SecureStoreGetFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback>(source, authenticationResult) { ++ /** Used to return easily convertible values to JS code */ ++ @Field override var value: Pair = Pair(source, authType.ordinal) ++} ++ ++class SecureStoreSetFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback(source, authenticationResult) { ++ @Field override var value: Int = authType.ordinal ++} ++ ++class SecureStoreOriginalFeedback(source: T, authenticationResult: BiometricPrompt.AuthenticationResult? = null): SecureStoreFeedback(source, authenticationResult) { ++ @Field override var value: T = source ++} ++ ++typealias SecureStoreNarrowedFeedback = SecureStoreFeedback +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt +index 1cd187f..d927ff1 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt +@@ -5,10 +5,12 @@ import android.security.keystore.KeyGenParameterSpec + import android.security.keystore.KeyProperties + import android.util.Base64 + import androidx.annotation.RequiresApi ++import androidx.biometric.BiometricPrompt + import expo.modules.securestore.AuthenticationHelper + import expo.modules.securestore.DecryptException + import expo.modules.securestore.SecureStoreModule + import expo.modules.securestore.SecureStoreOptions ++import expo.modules.securestore.SecureStoreOriginalFeedback + import org.json.JSONException + import org.json.JSONObject + import java.nio.charset.StandardCharsets +@@ -86,15 +88,23 @@ class AESEncryptor : KeyBasedEncryptor { + authenticationPrompt: String, + authenticationHelper: AuthenticationHelper, + enableDeviceFallback: Boolean, +- ): JSONObject { ++ ): SecureStoreOriginalFeedback { + val secretKey = keyStoreEntry.secretKey + val cipher = Cipher.getInstance(AES_CIPHER) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + val gcmSpec = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java) +- val authenticatedCipher = authenticationHelper.authenticateCipher(cipher, requireAuthentication, authenticationPrompt, enableDeviceFallback) ++ var promptResult: BiometricPrompt.AuthenticationResult? = null ++ val authenticatedCipher: Cipher ++ ++ if (requireAuthentication) { ++ promptResult = authenticationHelper.authenticateCipher(cipher, authenticationPrompt, enableDeviceFallback) ++ authenticatedCipher = promptResult.cryptoObject?.cipher ?: cipher ++ } else { ++ authenticatedCipher = cipher ++ } + +- return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec) ++ return SecureStoreOriginalFeedback(createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec), promptResult) + } + + internal fun createEncryptedItemWithCipher( +@@ -121,7 +131,7 @@ class AESEncryptor : KeyBasedEncryptor { + keyStoreEntry: KeyStore.SecretKeyEntry, + options: SecureStoreOptions, + authenticationHelper: AuthenticationHelper +- ): String { ++ ): SecureStoreOriginalFeedback { + val ciphertext = encryptedItem.getString(CIPHERTEXT_PROPERTY) + val ivString = encryptedItem.getString(IV_PROPERTY) + val authenticationTagLength = encryptedItem.getInt(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY) +@@ -135,8 +145,17 @@ class AESEncryptor : KeyBasedEncryptor { + throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService) + } + cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec) +- val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt, options.enableDeviceFallback) +- return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8) ++ var promptResult: BiometricPrompt.AuthenticationResult? = null ++ val unlockedCipher: Cipher ++ ++ if (requiresAuthentication) { ++ promptResult = authenticationHelper.authenticateCipher(cipher, options.authenticationPrompt, options.enableDeviceFallback) ++ unlockedCipher = promptResult.cryptoObject?.cipher ?: cipher ++ } else { ++ unlockedCipher = cipher ++ } ++ ++ return SecureStoreOriginalFeedback(String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8), promptResult) + } + + companion object { +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt +index 5f8bbfd..7527001 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt +@@ -9,6 +9,7 @@ import expo.modules.securestore.EncryptException + import expo.modules.securestore.KeyStoreException + import expo.modules.securestore.SecureStoreModule + import expo.modules.securestore.SecureStoreOptions ++import expo.modules.securestore.SecureStoreOriginalFeedback + import org.json.JSONException + import org.json.JSONObject + import java.security.GeneralSecurityException +@@ -71,7 +72,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto + authenticationPrompt: String, + authenticationHelper: AuthenticationHelper, + enableDeviceFallback: Boolean, +- ): JSONObject { ++ ): SecureStoreOriginalFeedback { + // This should never be called after we dropped Android SDK 22 support. + throw EncryptException( + "HybridAESEncryption should not be used on Android SDK >= 23. This shouldn't happen. " + +@@ -88,7 +89,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto + keyStoreEntry: KeyStore.PrivateKeyEntry, + options: SecureStoreOptions, + authenticationHelper: AuthenticationHelper +- ): String { ++ ): SecureStoreOriginalFeedback { + // Decrypt the encrypted symmetric key + val encryptedSecretKeyString = encryptedItem.getString(ENCRYPTED_SECRET_KEY_PROPERTY) + val encryptedSecretKeyBytes = Base64.decode(encryptedSecretKeyString, Base64.DEFAULT) +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt +index 39459ff..a531aff 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt +@@ -2,6 +2,7 @@ package expo.modules.securestore.encryptors + + import expo.modules.securestore.AuthenticationHelper + import expo.modules.securestore.SecureStoreOptions ++import expo.modules.securestore.SecureStoreOriginalFeedback + import org.json.JSONException + import org.json.JSONObject + import java.security.GeneralSecurityException +@@ -27,7 +28,7 @@ interface KeyBasedEncryptor { + authenticationPrompt: String, + authenticationHelper: AuthenticationHelper, + enableDeviceFallback: Boolean, +- ): JSONObject ++ ): SecureStoreOriginalFeedback + + @Throws(GeneralSecurityException::class, JSONException::class) + suspend fun decryptItem( +@@ -36,5 +37,5 @@ interface KeyBasedEncryptor { + keyStoreEntry: E, + options: SecureStoreOptions, + authenticationHelper: AuthenticationHelper +- ): String ++ ): SecureStoreOriginalFeedback + } +diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts +index 835a4d4..d2da8d9 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.d.ts ++++ b/node_modules/expo-secure-store/build/SecureStore.d.ts +@@ -1,4 +1,53 @@ ++type EmptyObject = Record; ++type SecureStoreSetFeedback = R['returnUsedAuthenticationType'] extends true ? AuthType : void; ++type SecureStoreGetFeedback = R['returnUsedAuthenticationType'] extends true ? [T | null, AuthType] : T | null; + export type KeychainAccessibilityConstant = number; ++/** ++ * Authentication type returned by the SecureStore after reading item or saving it to the store. ++ */ ++export declare const AUTH_TYPE: { ++ /** ++ * This is purely for backwards compatibility. ++ * Although it is not listed as a return value of the getAuthenticationType() method, ++ * it is still present in the Android code. ++ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() ++ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() ++ * @platform android ++ */ ++ readonly UNKNOWN: -1; ++ /** ++ * Returned when the authentication fails ++ * @platform android ++ * @platform ios ++ */ ++ readonly NONE: 0; ++ /** ++ * Generic type, not specified whether it was a passcode or pattern. ++ * @platform android ++ * @platform ios ++ */ ++ readonly CREDENTIALS: 1; ++ /** ++ * Generic type, not specified whether it was a face scan or a fingerprint ++ * @platform android ++ */ ++ readonly BIOMETRICS: 2; ++ /** ++ * FaceID was used to authenticate ++ * @platform ios ++ */ ++ readonly FACE_ID: 3; ++ /** ++ * TouchID was used to authenticate ++ * @platform ios ++ */ ++ readonly TOUCH_ID: 4; ++ /** ++ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) ++ */ ++ readonly OPTIC_ID: 5; ++}; ++type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; + /** + * The data in the keychain item cannot be accessed after a restart until the device has been + * unlocked once by the user. This may be useful if you need to access the item when the phone +@@ -88,6 +137,22 @@ export type SecureStoreOptions = { + * @platform ios + */ + enableDeviceFallback?: boolean; ++ /** ++ * When this flag is set to true, the get methods of SecureStore will return a two-element array. The first value will be the original value returned when this flag is set to false. ++ * The second value is the authentication type used to read the value from the AUTH_TYPE object. ++ * As for the set function, the returned value will simply be AUTH_TYPE. ++ * ++ * @warning ++ * If the iOS device supports biometrics and the user falls back to device credentials, it will not be detected. ++ * This is not the case on Android, but we cannot specify the exact type of biometrics (e.g. fingerprint or face scan). ++ * Whether the type is detected correctly depends on the platform and its native implementation. ++ * This should be treated as more of a hint. ++ * ++ * @default false ++ * @platform android ++ * @platform ios ++ */ ++ returnUsedAuthenticationType?: boolean; + }; + /** + * Returns whether the SecureStore API is enabled on the current device. This does not check the app +@@ -119,7 +184,7 @@ export declare function deleteItemAsync(key: string, options?: SecureStoreOption + * > After a key has been invalidated, it becomes impossible to read its value. + * > This only applies to values stored with `requireAuthentication` set to `true`. + */ +-export declare function getItemAsync(key: string, options?: SecureStoreOptions): Promise; ++export declare function getItemAsync(key: string, options?: R | EmptyObject): Promise>; + /** + * Stores a key–value pair. + * +@@ -129,7 +194,7 @@ export declare function getItemAsync(key: string, options?: SecureStoreOptions): + * + * @return A promise that rejects if value cannot be stored on the device. + */ +-export declare function setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise; ++export declare function setItemAsync(key: string, value: string, options?: R | EmptyObject): Promise>; + /** + * Stores a key–value pair synchronously. + * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when the `requireAuthentication` option is set to `true` until the user authenticates. +@@ -139,7 +204,7 @@ export declare function setItemAsync(key: string, value: string, options?: Secur + * @param options An [`SecureStoreOptions`](#securestoreoptions) object. + * + */ +-export declare function setItem(key: string, value: string, options?: SecureStoreOptions): void; ++export declare function setItem(key: string, value: string, options?: R | EmptyObject): SecureStoreSetFeedback; + /** + * Synchronously reads the stored value associated with the provided key. + * > **Note:** This function blocks the JavaScript thread, so the application may not be interactive when reading a value with `requireAuthentication` +@@ -150,7 +215,7 @@ export declare function setItem(key: string, value: string, options?: SecureStor + * @return Previously stored value. It resolves with `null` if there is no entry + * for the given key or if the key has been invalidated. + */ +-export declare function getItem(key: string, options?: SecureStoreOptions): string | null; ++export declare function getItem(key: string, options?: R | EmptyObject): SecureStoreGetFeedback; + /** + * Checks if the value can be saved with `requireAuthentication` option enabled. + * @return `true` if the device supports biometric authentication and the enrolled method is sufficiently secure. Otherwise, returns `false`. Always returns false on tvOS. +diff --git a/node_modules/expo-secure-store/build/SecureStore.js b/node_modules/expo-secure-store/build/SecureStore.js +index e11bd94..940fc61 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.js ++++ b/node_modules/expo-secure-store/build/SecureStore.js +@@ -1,6 +1,53 @@ + import ExpoSecureStore from './ExpoSecureStore'; + import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; + // @needsAudit ++ ++/** ++ * Authentication type returned by the SecureStore after reading item or saving it to the store. ++ */ ++export const AUTH_TYPE = { ++ /** ++ * This is purely for backwards compatibility. ++ * Although it is not listed as a return value of the getAuthenticationType() method, ++ * it is still present in the Android code. ++ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() ++ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() ++ * @platform android ++ */ ++ UNKNOWN: -1, ++ /** ++ * Returned when the authentication fails ++ * @platform android ++ * @platform ios ++ */ ++ NONE: 0, ++ /** ++ * Generic type, not specified whether it was a passcode or pattern. ++ * @platform android ++ * @platform ios ++ */ ++ CREDENTIALS: 1, ++ /** ++ * Generic type, not specified whether it was a face scan or a fingerprint ++ * @platform android ++ */ ++ BIOMETRICS: 2, ++ /** ++ * FaceID was used to authenticate ++ * @platform ios ++ */ ++ FACE_ID: 3, ++ /** ++ * TouchID was used to authenticate ++ * @platform ios ++ */ ++ TOUCH_ID: 4, ++ /** ++ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) ++ */ ++ OPTIC_ID: 5, ++}; ++// @needsAudit + /** + * The data in the keychain item cannot be accessed after a restart until the device has been + * unlocked once by the user. This may be useful if you need to access the item when the phone +@@ -102,7 +149,7 @@ export async function setItemAsync(key, value, options = {}) { + if (!isValidValue(value)) { + throw new Error(`Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.`); + } +- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); ++ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); + } + /** + * Stores a key–value pair synchronously. +diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +index 1268979..7764c8e 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +@@ -18,28 +18,36 @@ public final class SecureStoreModule: Module { + "WHEN_UNLOCKED_THIS_DEVICE_ONLY": SecureStoreAccessible.whenUnlockedThisDeviceOnly.rawValue + ]) + +- AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in +- return try get(with: key, options: options) ++ AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in ++ let result = try get(with: key, options: options) ++ ++ return wrapResultWithFeedback(action: .get, result: result, options: options).value + } + +- Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in +- return try get(with: key, options: options) ++ Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) in ++ let result = try get(with: key, options: options) ++ ++ return wrapResultWithFeedback(action: .get, result: result, options: options).value + } + +- AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in ++ AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) in + guard let key = validate(for: key) else { + throw InvalidKeyException() + } + +- return try set(value: value, with: key, options: options) ++ let result = try set(value: value, with: key, options: options) ++ ++ return wrapResultWithFeedback(action: .set, result: result, options: options).value + } + +- Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in ++ Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) in + guard let key = validate(for: key) else { + throw InvalidKeyException() + } + +- return try set(value: value, with: key, options: options) ++ let result = try set(value: value, with: key, options: options) ++ ++ return wrapResultWithFeedback(action: .set, result: result, options: options).value + } + + AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in +@@ -53,18 +61,7 @@ public final class SecureStoreModule: Module { + } + + Function("canUseBiometricAuthentication") {() -> Bool in +- #if os(tvOS) +- return false +- #else +- let context = LAContext() +- var error: NSError? +- let isBiometricsSupported: Bool = context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error) +- +- if error != nil { +- return false +- } +- return isBiometricsSupported +- #endif ++ return areBiometricsEnabled() + } + + Function("canUseDeviceCredentialsAuthentication") { () -> Bool in +@@ -76,6 +73,41 @@ public final class SecureStoreModule: Module { + return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthentication, error: nil) + } + ++ private func getAuthType() -> AuthType { ++ if !areBiometricsEnabled() {return AuthType.credentials} ++ let biometryType = LAContext().biometryType ++ ++ switch biometryType { ++ case .faceID: return .faceID ++ case .touchID: return .touchID ++ case .opticID: return .opticID // available since iOS 17 ++ case .none: fallthrough // this one continues to the next line ++ @unknown default: return .credentials ++ } ++ } ++ ++ private func wrapResultWithFeedback(action: SecureStoreFeedbackAction, result: T, options: SecureStoreOptions) -> any SecureStoreFeedback { ++ let authType = getAuthType().rawValue ++ ++ if (!options.returnUsedAuthenticationType) { ++ return SecureStoreOriginalFeedback(source: result, authType: authType) ++ } ++ ++ if (action == .get) { ++ return SecureStoreGetFeedback(source: result, authType: authType) ++ } ++ ++ return SecureStoreSetFeedback(source: result, authType: authType) ++ } ++ ++ private func areBiometricsEnabled() -> Bool { ++ #if os(tvOS) ++ return false ++ #else ++ return LAContext().canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: nil) ++ #endif ++ } ++ + private func get(with key: String, options: SecureStoreOptions) throws -> String? { + guard let key = validate(for: key) else { + throw InvalidKeyException() +diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +index c9e843b..b95eca7 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +@@ -18,4 +18,71 @@ internal struct SecureStoreOptions: Record { + + @Field + var enableDeviceFallback: Bool = false ++ ++ @Field ++ var returnUsedAuthenticationType: Bool = false ++} ++ ++@available(iOS 11.2, *) ++public enum AuthType: Int, @unchecked Sendable { ++ /// The device does not support biometry. ++ case none = 0 ++ ++ /// The device supports device credentials ++ case credentials = 1 ++ ++ /// Generic type, not specified whether it was a faceID or touchID ++ case biometrics = 2 ++ ++ /// The device supports Face ID. ++ case faceID = 3 ++ ++ /// The device supports Touch ID. ++ case touchID = 4 ++ ++ /// The device supports Optic ID ++ case opticID = 5 ++} ++ ++public enum SecureStoreFeedbackAction: String { ++ case set ++ case get ++} ++ ++protocol SecureStoreFeedback { ++ associatedtype Source ++ associatedtype Value ++ var source: Source { get } ++ var authType: Int { get } ++ var value: Value { get } ++} ++ ++struct SecureStoreGetFeedback: SecureStoreFeedback { ++ typealias Source = T ++ typealias Value = Array ++ var source: Source ++ var authType: Int = AuthType.none.rawValue ++ var value: Value { ++ get { return [source, authType] } ++ } ++} ++ ++struct SecureStoreOriginalFeedback: SecureStoreFeedback { ++ typealias Source = T ++ typealias Value = T ++ var source: Source ++ var authType: Int = AuthType.none.rawValue ++ var value: Value { ++ get { return source } ++ } ++} ++ ++struct SecureStoreSetFeedback: SecureStoreFeedback { ++ typealias Source = T ++ typealias Value = Int ++ var source: Source ++ var authType: Int = AuthType.none.rawValue ++ var value: Value { ++ get { return authType } ++ } + } +diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts +index 4f253a1..d44c0bb 100644 +--- a/node_modules/expo-secure-store/src/SecureStore.ts ++++ b/node_modules/expo-secure-store/src/SecureStore.ts +@@ -1,8 +1,63 @@ + import ExpoSecureStore from './ExpoSecureStore'; + import { byteCountOverLimit, VALUE_BYTES_LIMIT } from './byteCounter'; + ++type EmptyObject = Record; ++type SecureStoreSetFeedback = ++ R['returnUsedAuthenticationType'] extends true ? AuthType : void; ++type SecureStoreGetFeedback< ++ T, ++ R extends SecureStoreOptions, ++> = R['returnUsedAuthenticationType'] extends true ? [T | null, AuthType] : T | null; + export type KeychainAccessibilityConstant = number; + ++/** ++ * Authentication type returned by the SecureStore after reading item or saving it to the store. ++ */ ++export const AUTH_TYPE = { ++ /** ++ * This is purely for backwards compatibility. ++ * Although it is not listed as a return value of the getAuthenticationType() method, ++ * it is still present in the Android code. ++ * @see https://developer.android.com/reference/android/hardware/biometrics/BiometricPrompt.AuthenticationResult#getAuthenticationType() ++ * @see https://developer.android.com/reference/androidx/biometric/BiometricPrompt#AUTHENTICATION_RESULT_TYPE_UNKNOWN() ++ * @platform android ++ */ ++ UNKNOWN: -1, ++ /** ++ * Returned when the authentication fails ++ * @platform android ++ * @platform ios ++ */ ++ NONE: 0, ++ /** ++ * Generic type, not specified whether it was a passcode or pattern. ++ * @platform android ++ * @platform ios ++ */ ++ CREDENTIALS: 1, ++ /** ++ * Generic type, not specified whether it was a face scan or a fingerprint ++ * @platform android ++ */ ++ BIOMETRICS: 2, ++ /** ++ * FaceID was used to authenticate ++ * @platform ios ++ */ ++ FACE_ID: 3, ++ /** ++ * TouchID was used to authenticate ++ * @platform ios ++ */ ++ TOUCH_ID: 4, ++ /** ++ * OpticID was used to authenticate (reserved by apple, used on Apple Vision Pro, not iOS) ++ */ ++ OPTIC_ID: 5, ++} as const; ++ ++type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; ++ + // @needsAudit + /** + * The data in the keychain item cannot be accessed after a restart until the device has been +@@ -113,6 +168,23 @@ export type SecureStoreOptions = { + * @platform ios + */ + enableDeviceFallback?: boolean; ++ ++ /** ++ * When this flag is set to true, the get methods of SecureStore will return a two-element array. The first value will be the original value returned when this flag is set to false. ++ * The second value is the authentication type used to read the value from the AUTH_TYPE object. ++ * As for the set function, the returned value will simply be AUTH_TYPE. ++ * ++ * @warning ++ * If the iOS device supports biometrics and the user falls back to device credentials, it will not be detected. ++ * This is not the case on Android, but we cannot specify the exact type of biometrics (e.g. fingerprint or face scan). ++ * Whether the type is detected correctly depends on the platform and its native implementation. ++ * This should be treated as more of a hint. ++ * ++ * @default false ++ * @platform android ++ * @platform ios ++ */ ++ returnUsedAuthenticationType?: boolean; + }; + + // @needsAudit +@@ -159,10 +231,10 @@ export async function deleteItemAsync( + * > After a key has been invalidated, it becomes impossible to read its value. + * > This only applies to values stored with `requireAuthentication` set to `true`. + */ +-export async function getItemAsync( ++export async function getItemAsync( + key: string, +- options: SecureStoreOptions = {} +-): Promise { ++ options: R | EmptyObject = {} ++): Promise> { + ensureValidKey(key); + return await ExpoSecureStore.getValueWithKeyAsync(key, options); + } +@@ -177,11 +249,11 @@ export async function getItemAsync( + * + * @return A promise that rejects if value cannot be stored on the device. + */ +-export async function setItemAsync( ++export async function setItemAsync( + key: string, + value: string, +- options: SecureStoreOptions = {} +-): Promise { ++ options: R | EmptyObject = {} ++): Promise> { + ensureValidKey(key); + if (!isValidValue(value)) { + throw new Error( +@@ -189,7 +261,7 @@ export async function setItemAsync( + ); + } + +- await ExpoSecureStore.setValueWithKeyAsync(value, key, options); ++ return await ExpoSecureStore.setValueWithKeyAsync(value, key, options); + } + + /** +@@ -201,7 +273,11 @@ export async function setItemAsync( + * @param options An [`SecureStoreOptions`](#securestoreoptions) object. + * + */ +-export function setItem(key: string, value: string, options: SecureStoreOptions = {}): void { ++export function setItem( ++ key: string, ++ value: string, ++ options: R | EmptyObject = {} ++): SecureStoreSetFeedback { + ensureValidKey(key); + if (!isValidValue(value)) { + throw new Error( +@@ -222,7 +298,10 @@ export function setItem(key: string, value: string, options: SecureStoreOptions + * @return Previously stored value. It resolves with `null` if there is no entry + * for the given key or if the key has been invalidated. + */ +-export function getItem(key: string, options: SecureStoreOptions = {}): string | null { ++export function getItem( ++ key: string, ++ options: R | EmptyObject = {} ++): SecureStoreGetFeedback { + ensureValidKey(key); + return ExpoSecureStore.getValueWithKeySync(key, options); + } diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch new file mode 100644 index 000000000000..59e74054b162 --- /dev/null +++ b/patches/expo-secure-store/expo-secure-store+14.2.4+003+force-authentication-on-save.patch @@ -0,0 +1,124 @@ +diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts +index 7097a8a..975d75b 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.d.ts ++++ b/node_modules/expo-secure-store/build/SecureStore.d.ts +@@ -153,6 +153,20 @@ export type SecureStoreOptions = { + * @platform ios + */ + returnUsedAuthenticationType?: boolean; ++ /** ++ * On iOS, the system does not ask for auth when saving the value to the SecureStore. ++ * The Android however, displays the authentication prompt when saving the value. ++ * To keep the behavior on every platform similar as much as possible, ++ * setting this flag to true will ensure that authentication is required when saving a value to the store. ++ * ++ * @warning: This flag only works for the asynchronous version of the SecureStore save method. ++ * It should only be considered an improvement to the user experience; it does not prevent the user ++ * from saving the value without authenticating using other methods (e.g. by directly modifying the keychain). ++ * For it to take effect, the 'requireAuthentication' flag must be set to true. ++ * @default false ++ * @platform ios ++ */ ++ forceAuthenticationOnSave?: boolean; + }; + /** + * Returns whether the SecureStore API is enabled on the current device. This does not check the app +diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +index 7764c8e..02f837c 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +@@ -35,6 +35,10 @@ public final class SecureStoreModule: Module { + throw InvalidKeyException() + } + ++ if options.requireAuthentication && options.forceAuthenticationOnSave { ++ try await triggerPolicy(options: options) ++ } ++ + let result = try set(value: value, with: key, options: options) + + return wrapResultWithFeedback(action: .set, result: result, options: options).value +@@ -190,6 +194,32 @@ public final class SecureStoreModule: Module { + } + } + ++ @MainActor ++ private func triggerPolicy(options: SecureStoreOptions) async throws { ++ let isPolicyAvailable = options.enableDeviceFallback ? areDeviceCredentialsEnabled() : areBiometricsEnabled() ++ ++ guard isPolicyAvailable else { ++ throw SecureStoreRuntimeError("No authentication method available") ++ } ++ ++ let localAuthPolicy: LAPolicy = options.enableDeviceFallback ? .deviceOwnerAuthentication : .deviceOwnerAuthenticationWithBiometrics ++ let localizedReason: String = options.authenticationPrompt ?? "Authentication required" ++ ++ let success: Bool = try await withCheckedThrowingContinuation { continuation in ++ LAContext().evaluatePolicy(localAuthPolicy, localizedReason: localizedReason) { success, error in ++ if let error = error { ++ continuation.resume(throwing: error) ++ } else { ++ continuation.resume(returning: success) ++ } ++ } ++ } ++ ++ guard success else { ++ throw SecureStoreRuntimeError("Unable to authenticate") ++ } ++ } ++ + private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? { + var query = query(with: key, options: options, requireAuthentication: requireAuthentication) + +diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +index b95eca7..321c7e5 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +@@ -21,6 +21,9 @@ internal struct SecureStoreOptions: Record { + + @Field + var returnUsedAuthenticationType: Bool = false ++ ++ @Field ++ var forceAuthenticationOnSave: Bool = false + } + + @available(iOS 11.2, *) +@@ -86,3 +89,10 @@ struct SecureStoreSetFeedback: SecureStoreFeedback { + get { return authType } + } + } ++ ++struct SecureStoreRuntimeError: LocalizedError { ++ let errorDescription: String? ++ init(_ description: String) { ++ self.errorDescription = description ++ } ++} +diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts +index d44c0bb..c4b95ae 100644 +--- a/node_modules/expo-secure-store/src/SecureStore.ts ++++ b/node_modules/expo-secure-store/src/SecureStore.ts +@@ -185,6 +185,21 @@ export type SecureStoreOptions = { + * @platform ios + */ + returnUsedAuthenticationType?: boolean; ++ ++ /** ++ * On iOS, the system does not ask for auth when saving the value to the SecureStore. ++ * The Android however, displays the authentication prompt when saving the value. ++ * To keep the behavior on every platform similar as much as possible, ++ * setting this flag to true will ensure that authentication is required when saving a value to the store. ++ * ++ * @warning: This flag only works for the asynchronous version of the SecureStore save method. ++ * It should only be considered an improvement to the user experience; it does not prevent the user ++ * from saving the value without authenticating using other methods (e.g. by directly modifying the keychain). ++ * For it to take effect, the 'requireAuthentication' flag must be set to true. ++ * @default false ++ * @platform ios ++ */ ++ forceAuthenticationOnSave?: boolean; + }; + + // @needsAudit diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch new file mode 100644 index 000000000000..c3acba54c616 --- /dev/null +++ b/patches/expo-secure-store/expo-secure-store+14.2.4+004+fail-on-update.patch @@ -0,0 +1,100 @@ +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +index d209efc..e362aef 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +@@ -212,6 +212,10 @@ open class SecureStoreModule : Module() { + return SecureStoreOriginalFeedback(null) + } + ++ if (prefs.contains(keychainAwareKey) && options.failOnUpdate) { ++ throw WriteException("Key already exists", key, options.keychainService) ++ } ++ + try { + if (keyIsInvalidated) { + // Invalidated keys will block writing even though it's not possible to re-validate them +diff --git a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +index 4e30440..16674b8 100644 +--- a/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt ++++ b/node_modules/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +@@ -11,7 +11,8 @@ class SecureStoreOptions( + @Field var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS, + @Field var requireAuthentication: Boolean = false, + @Field var enableDeviceFallback: Boolean = false, +- @Field var returnUsedAuthenticationType: Boolean = false ++ @Field var returnUsedAuthenticationType: Boolean = false, ++ @Field var failOnUpdate: Boolean = false + ) : Record, Serializable + + enum class SecureStoreAuthType(index: Int) { +diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts +index f1e3610..8954c59 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.d.ts ++++ b/node_modules/expo-secure-store/build/SecureStore.d.ts +@@ -167,6 +167,16 @@ export type SecureStoreOptions = { + * @platform ios + */ + forceAuthenticationOnSave?: boolean; ++ /** ++ * If the key has already been stored, the save function will throw an error instead of overwriting it. ++ * The behaviour differs slightly depending on the platform. ++ * On Android, an error is thrown before the authentication prompt. On iOS, an error is thrown after authentication. ++ * ++ * @default false ++ * @platform ios ++ * @platform android ++ */ ++ failOnUpdate?: boolean; + }; + /** + * Returns whether the SecureStore API is enabled on the current device. This does not check the app +diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +index 02f837c..47282db 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +@@ -169,6 +169,9 @@ public final class SecureStoreModule: Module { + SecItemDelete(query(with: key, options: options, requireAuthentication: !options.requireAuthentication) as CFDictionary) + return true + case errSecDuplicateItem: ++ if options.failOnUpdate { ++ throw SecureStoreRuntimeError("Key already exists") ++ } + return try update(value: value, with: key, options: options) + default: + throw KeyChainException(status) +diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +index 321c7e5..6a787b0 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +@@ -24,6 +24,9 @@ internal struct SecureStoreOptions: Record { + + @Field + var forceAuthenticationOnSave: Bool = false ++ ++ @Field ++ var failOnUpdate: Bool = false + } + + @available(iOS 11.2, *) +diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts +index c4b95ae..0bedcaa 100644 +--- a/node_modules/expo-secure-store/src/SecureStore.ts ++++ b/node_modules/expo-secure-store/src/SecureStore.ts +@@ -200,6 +200,17 @@ export type SecureStoreOptions = { + * @platform ios + */ + forceAuthenticationOnSave?: boolean; ++ ++ /** ++ * If the key has already been stored, the save function will throw an error instead of overwriting it. ++ * The behaviour differs slightly depending on the platform. ++ * On Android, an error is thrown before the authentication prompt. On iOS, an error is thrown after authentication. ++ * ++ * @default false ++ * @platform ios ++ * @platform android ++ */ ++ failOnUpdate?: boolean; + }; + + // @needsAudit diff --git a/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch b/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch new file mode 100644 index 000000000000..f65e0a31540a --- /dev/null +++ b/patches/expo-secure-store/expo-secure-store+14.2.4+005+force-read-authentication-on-simulators.patch @@ -0,0 +1,77 @@ +diff --git a/node_modules/expo-secure-store/build/SecureStore.d.ts b/node_modules/expo-secure-store/build/SecureStore.d.ts +index 836958c..29531c5 100644 +--- a/node_modules/expo-secure-store/build/SecureStore.d.ts ++++ b/node_modules/expo-secure-store/build/SecureStore.d.ts +@@ -177,6 +177,18 @@ export type SecureStoreOptions = { + * @platform android + */ + failOnUpdate?: boolean; ++ /** ++ * The LocalAuthentication behaves slightly differently on iOS simulators. ++ * In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). ++ * Setting this flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. ++ * This is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. ++ * This has no effect on real devices. ++ * ++ * @warning: This flag only works for the asynchronous version of the SecureStore read method. ++ * @default false ++ * @platform ios ++ */ ++ forceReadAuthenticationOnSimulators?: boolean; + }; + /** + * Returns whether the SecureStore API is enabled on the current device. This does not check the app +diff --git a/node_modules/expo-secure-store/ios/SecureStoreModule.swift b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +index 47282db..dcbe242 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreModule.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreModule.swift +@@ -19,6 +19,11 @@ public final class SecureStoreModule: Module { + ]) + + AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in ++ #if targetEnvironment(simulator) ++ if options.requireAuthentication && options.forceReadAuthenticationOnSimulators { ++ try await triggerPolicy(options: options) ++ } ++ #endif + let result = try get(with: key, options: options) + + return wrapResultWithFeedback(action: .get, result: result, options: options).value +diff --git a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +index 6a787b0..8f110dd 100644 +--- a/node_modules/expo-secure-store/ios/SecureStoreOptions.swift ++++ b/node_modules/expo-secure-store/ios/SecureStoreOptions.swift +@@ -27,6 +27,9 @@ internal struct SecureStoreOptions: Record { + + @Field + var failOnUpdate: Bool = false ++ ++ @Field ++ var forceReadAuthenticationOnSimulators: Bool = false + } + + @available(iOS 11.2, *) +diff --git a/node_modules/expo-secure-store/src/SecureStore.ts b/node_modules/expo-secure-store/src/SecureStore.ts +index 0bedcaa..bae5580 100644 +--- a/node_modules/expo-secure-store/src/SecureStore.ts ++++ b/node_modules/expo-secure-store/src/SecureStore.ts +@@ -211,6 +211,19 @@ export type SecureStoreOptions = { + * @platform android + */ + failOnUpdate?: boolean; ++ ++ /** ++ * The LocalAuthentication behaves slightly differently on iOS simulators. ++ * In numerous cases, the authentication prompts are skipped on simulators (as opposed to real devices). ++ * Setting this flag to true forces the prompt to appear on simulators when a value with the `requireAuthentication` flag set to true is read. ++ * This is purely for testing the app on simulators, in cases where the prompt does not appear when the value is read. ++ * This has no effect on real devices. ++ * ++ * @warning: This flag only works for the asynchronous version of the SecureStore read method. ++ * @default false ++ * @platform ios ++ */ ++ forceReadAuthenticationOnSimulators?: boolean; + }; + + // @needsAudit