feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');

View File

@@ -0,0 +1,219 @@
# Changelog
## Unpublished
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
### 💡 Others
## 13.0.2 — 2024-06-27
### 🐛 Bug fixes
- [iOS] Improve error message for unhandled errors ([#29394](https://github.com/expo/expo/pull/29394) by [@hassankhan](https://github.com/hassankhan))
- [Android] Fix decryption errors after Android Auto Backup has restored `expo-secure-store` data. ([#29943](https://github.com/expo/expo/pull/29943) by [@behenate](https://github.com/behenate))
## 13.0.1 — 2024-04-23
_This version does not introduce any user-facing changes._
## 13.0.0 — 2024-04-18
### 🎉 New features
- Add ability to disable permissions in config plugin by passing `false` instead of permission messages. ([#28107](https://github.com/expo/expo/pull/28107) by [@EvanBacon](https://github.com/EvanBacon))
- Add `canUseBiometricAuthentication` function. ([#26767](https://github.com/expo/expo/pull/26767) by [@behenate](https://github.com/behenate))
### 💡 Others
- drop unused web `name` property. ([#27437](https://github.com/expo/expo/pull/27437) by [@EvanBacon](https://github.com/EvanBacon))
- Removed deprecated backward compatible Gradle settings. ([#28083](https://github.com/expo/expo/pull/28083) by [@kudo](https://github.com/kudo))
## 12.8.1 - 2023-12-19
_This version does not introduce any user-facing changes._
## 12.8.0 — 2023-12-12
### 🎉 New features
- [iOS] Added possibility to store values that require authentication and ones that don't under the same `keychainService`. ([#23841](https://github.com/expo/expo/pull/23841) by [@behenate](https://github.com/behenate))
- [iOS] Added synchronous functions for storing and retrieving values from the store. ([#23841](https://github.com/expo/expo/pull/23841) by [@behenate](https://github.com/behenate))
## 12.7.0 — 2023-11-14
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.4. ([#25063](https://github.com/expo/expo/pull/25063) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- On `Android` bump `compileSdkVersion` and `targetSdkVersion` to `34`. ([#24708](https://github.com/expo/expo/pull/24708) by [@alanjhughes](https://github.com/alanjhughes))
### 💡 Others
- [Android] Enforce minimum authentication tag length for the `AESEncryptor` for improved security. ([#25294](https://github.com/expo/expo/pull/25294) by [@behenate](https://github.com/behenate))
## 12.6.0 — 2023-10-17
### 🛠 Breaking changes
- Dropped support for Android SDK 21 and 22. ([#24201](https://github.com/expo/expo/pull/24201) by [@behenate](https://github.com/behenate))
### 🐛 Bug fixes
- Fixed the 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' constraint being incorrectly mapped to wrong secure store accessible ([#24831](https://github.com/expo/expo/pull/24831) by [@mmmguitar](https://github.com/mmmguitar))
## 12.5.0 — 2023-09-04
### 🎉 New features
- [Android] Migrated to Expo Modules API. ([#23804](https://github.com/expo/expo/pull/23804) by [@behenate](https://github.com/behenate))
- [Android] It is now possible to store values that require authentication and ones that don't under the same `keychainService`. ([#23804](https://github.com/expo/expo/pull/23804) by [@behenate](https://github.com/behenate))
- Added support for React Native 0.73. ([#24018](https://github.com/expo/expo/pull/24018) by [@kudo](https://github.com/kudo))
## 12.4.1 — 2023-08-02
_This version does not introduce any user-facing changes._
## 12.4.0 — 2023-07-28
### 🎉 New features
- Added a config plugin to automatically set NSFaceIDUsageDescription on iOS. ([#23268](https://github.com/expo/expo/pull/23268) by [@aleqsio](https://github.com/aleqsio))
## 12.3.1 - 2023-07-04
### 💡 Others
- Added a check for the `NSFaceIDUsageDescription` key in the `set` function. ([#23275](https://github.com/expo/expo/pull/23275) by [@alanjhughes](https://github.com/alanjhughes))
## 12.3.0 — 2023-06-13
### 🐛 Bug fixes
- Fixed Android build warnings for Gradle version 8. ([#22537](https://github.com/expo/expo/pull/22537), [#22609](https://github.com/expo/expo/pull/22609) by [@kudo](https://github.com/kudo))
### 💡 Others
- Added automatic invalidated key handling on Android. ([#22716](https://github.com/expo/expo/pull/22716) by [@behenate](https://github.com/behenate))
## 12.2.0 — 2023-05-08
### 🎉 New features
- Migrated iOS codebase to use Expo modules API. ([#21393](https://github.com/expo/expo/pull/21393) by [@alanjhughes](https://github.com/alanjhughes))
## 12.1.1 — 2023-02-09
_This version does not introduce any user-facing changes._
## 12.1.0 — 2023-02-03
### 💡 Others
- On Android bump `compileSdkVersion` and `targetSdkVersion` to `33`. ([#20721](https://github.com/expo/expo/pull/20721) by [@lukmccall](https://github.com/lukmccall))
## 12.0.0 — 2022-10-25
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.0 and deprecated support for iOS 12. ([#18873](https://github.com/expo/expo/pull/18873) by [@tsapeta](https://github.com/tsapeta))
### 🐛 Bug fixes
- Fixed missing `code` and `message` in promise errors. ([#19555](https://github.com/expo/expo/pull/19555) by [@tsapeta](https://github.com/tsapeta))
### ⚠️ Notices
- Changed `requireAuthentication` option to also require biometrics on iOS (matches Android behavior) ([#18591](https://github.com/expo/expo/pull/18591) by [@stefan-schweiger](https://github.com/stefan-schweiger))
## 11.3.0 — 2022-07-07
_This version does not introduce any user-facing changes._
## 11.2.0 — 2022-04-18
### ⚠️ Notices
- On Android bump `compileSdkVersion` to `31`, `targetSdkVersion` to `31` and `Java` version to `11`. ([#16941](https://github.com/expo/expo/pull/16941) by [@bbarthec](https://github.com/bbarthec))
## 11.1.1 - 2022-02-01
### 🐛 Bug fixes
- Fix `Plugin with id 'maven' not found` build error from Android Gradle 7. ([#16080](https://github.com/expo/expo/pull/16080) by [@kudo](https://github.com/kudo))
## 11.1.0 — 2021-12-03
_This version does not introduce any user-facing changes._
## 11.0.1 — 2021-10-01
_This version does not introduce any user-facing changes._
## 11.0.0 — 2021-09-28
### 🛠 Breaking changes
- Dropped support for iOS 11.0 ([#14383](https://github.com/expo/expo/pull/14383) by [@cruzach](https://github.com/cruzach))
### 🐛 Bug fixes
- Fix building errors from use_frameworks! in Podfile. ([#14523](https://github.com/expo/expo/pull/14523) by [@kudo](https://github.com/kudo))
### 💡 Others
- Migrated from `@unimodules/core` to `expo-modules-core`. ([#13757](https://github.com/expo/expo/pull/13757) by [@tsapeta](https://github.com/tsapeta))
## 10.2.0 — 2021-06-16
### 🐛 Bug fixes
- Enable kotlin in all modules. ([#12716](https://github.com/expo/expo/pull/12716) by [@wschurman](https://github.com/wschurman))
### 💡 Others
- Build Android code using Java 8 to fix Android instrumented test build error. ([#12939](https://github.com/expo/expo/pull/12939) by [@kudo](https://github.com/kudo))
## 10.1.0 — 2021-03-10
### 🎉 New features
- Updated Android build configuration to target Android 11 (added support for Android SDK 30). ([#11647](https://github.com/expo/expo/pull/11647) by [@bbarthec](https://github.com/bbarthec))
### 🐛 Bug fixes
- Data saved with `expo-secure-store` is no longer lost upon ejecting, **if you first upgrade your app to SDK 41 before ejecting**. ([#11309](https://github.com/expo/expo/pull/11309) by [@cruzach](https://github.com/cruzach))
> On Android, all of your `SecureStore` data will be migrated on app start-up. On iOS, keys and their associated data will be migrated whenever you call `getItemAsync` on that key. This means that any keys you don't `get` while on SDK 41 will **not** be migrated.
## 10.0.0 — 2021-01-15
### 🛠 Breaking changes- Dropped support for iOS 10.0 ([#11344](https://github.com/expo/expo/pull/11344) by [@tsapeta](https://github.com/tsapeta))
## 9.3.0 — 2020-11-17
_This version does not introduce any user-facing changes._
## 9.2.0 — 2020-08-11
### 🎉 New features
- Create `isAvailableAsync` method. ([#9668](https://github.com/expo/expo/pull/9668) by [@EvanBacon](https://github.com/EvanBacon))
## 9.1.0 — 2020-07-27
### 🐛 Bug fixes
- Fix incorrect security attribute applied when using the flag WHEN_UNLOCKED_THIS_DEVICE_ONLY on iOS ([#9264](https://github.com/expo/expo/pull/9264) by [@cjthompson](https://github.com/cjthompson))
## 9.0.1 — 2020-05-29
_This version does not introduce any user-facing changes._
## 9.0.0 — 2020-05-27
### 🛠 Breaking changes
- The base64 output will no longer contain newline and special character (`\n`, `\r`) on Android. ([#7841](https://github.com/expo/expo/pull/7841) by [@jarvisluong](https://github.com/jarvisluong))

View File

@@ -0,0 +1,41 @@
<p>
<a href="https://docs.expo.dev/versions/latest/sdk/securestore/">
<img
src="../../.github/resources/expo-secure-store.svg"
alt="expo-secure-store"
height="64" />
</a>
</p>
Provides a way to encrypt and securely store keyvalue pairs locally on the device.
# API documentation
- [Documentation for the main branch](https://github.com/expo/expo/blob/main/docs/pages/versions/unversioned/sdk/securestore.mdx)
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/securestore/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/securestore/).
# Installation in bare React Native projects
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
### Add the package to your npm dependencies
```
npx expo install expo-secure-store
```
### Configure for iOS
Run `npx pod-install` after installing the npm package.
### Configure for Android
No additional set up necessary.
# Contributing
Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).

View File

@@ -0,0 +1,23 @@
apply plugin: 'com.android.library'
group = 'host.exp.exponent'
version = '13.0.2'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
useExpoPublishing()
android {
namespace "expo.modules.securestore"
defaultConfig {
versionCode 17
versionName '13.0.2'
}
}
dependencies {
api "androidx.biometric:biometric:1.1.0"
}

View File

@@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@@ -0,0 +1,89 @@
package expo.modules.securestore
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import expo.modules.core.ModuleRegistry
import expo.modules.core.interfaces.ActivityProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.crypto.Cipher
class AuthenticationHelper(
private val context: Context,
private val moduleRegistry: ModuleRegistry
) {
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")
}
return cipher
}
private suspend fun openAuthenticationPrompt(
cipher: Cipher,
title: String
): BiometricPrompt.AuthenticationResult {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
throw AuthenticationException("Biometric authentication requires Android API 23")
}
if (isAuthenticating) {
throw AuthenticationException("Authentication is already in progress")
}
isAuthenticating = true
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)
return withContext(Dispatchers.Main.immediate) {
try {
return@withContext authenticationPrompt.authenticate(cipher)
?: throw AuthenticationException("Couldn't get the authentication result")
} finally {
isAuthenticating = false
}
}
}
fun assertBiometricsSupport() {
val biometricManager = BiometricManager.from(context)
@SuppressLint("SwitchIntDef") // BiometricManager.BIOMETRIC_SUCCESS shouldn't do anything
when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
throw AuthenticationException("No hardware available for biometric authentication. Use expo-local-authentication to check if the device supports it")
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
throw AuthenticationException("No biometrics are currently enrolled")
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
throw AuthenticationException("An update is required before the biometrics can be used")
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
throw AuthenticationException("Biometric authentication is unsupported")
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
throw AuthenticationException("Biometric authentication status is unknown")
}
}
}
private fun getCurrentActivity(): Activity? {
val activityProvider: ActivityProvider = moduleRegistry.getModule(ActivityProvider::class.java)
return activityProvider.currentActivity
}
companion object {
const val REQUIRE_AUTHENTICATION_PROPERTY = "requireAuthentication"
}
}

View File

@@ -0,0 +1,44 @@
package expo.modules.securestore
import android.content.Context
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.PromptInfo
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.util.concurrent.Executor
import javax.crypto.Cipher
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) {
private var executor: Executor = ContextCompat.getMainExecutor(context)
private var promptInfo = PromptInfo.Builder()
.setTitle(title)
.setNegativeButtonText(context.getString(android.R.string.cancel))
.build()
suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? =
suspendCoroutine { continuation ->
BiometricPrompt(
currentActivity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
continuation.resumeWithException(AuthenticationException("User canceled the authentication"))
} else {
continuation.resumeWithException(AuthenticationException("Could not authenticate the user"))
}
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
continuation.resume(result)
}
}
).authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}

View File

@@ -0,0 +1,24 @@
package expo.modules.securestore
import expo.modules.kotlin.exception.CodedException
internal class NullKeyException :
CodedException("SecureStore keys must not be null")
internal class WriteException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
CodedException("An error occurred when writing to key: '$key' under keychain: '$keychain'. Caused by: ${message ?: "unknown"}", cause)
internal class EncryptException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
CodedException("Could not encrypt the value for key '$key' under keychain '$keychain'. Caused by: ${message ?: "unknown"}", cause)
internal class DecryptException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
CodedException("Could not decrypt the value for key '$key' under keychain '$keychain'. Caused by: ${message ?: "unknown"}", cause)
internal class DeleteException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
CodedException("Could not delete the value for key '$key' under keychain '$keychain'. Caused by: ${message ?: "unknown"}", cause)
internal class AuthenticationException(message: String?, cause: Throwable? = null) :
CodedException("Could not Authenticate the user: ${message ?: "unknown"}", cause)
internal class KeyStoreException(message: String?) :
CodedException("An error occurred when accessing the keystore: ${message ?: "unknown"}")

View File

@@ -0,0 +1,389 @@
package expo.modules.securestore
import android.content.Context
import android.content.SharedPreferences
import android.preference.PreferenceManager
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.util.Log
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.securestore.encryptors.AESEncryptor
import expo.modules.securestore.encryptors.HybridAESEncryptor
import expo.modules.securestore.encryptors.KeyBasedEncryptor
import kotlinx.coroutines.runBlocking
import org.json.JSONException
import org.json.JSONObject
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.KeyStore.PrivateKeyEntry
import java.security.KeyStore.SecretKeyEntry
import javax.crypto.BadPaddingException
open class SecureStoreModule : Module() {
private val mAESEncryptor = AESEncryptor()
open val reactContext: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private lateinit var keyStore: KeyStore
private lateinit var hybridAESEncryptor: HybridAESEncryptor
private lateinit var authenticationHelper: AuthenticationHelper
override fun definition() = ModuleDefinition {
Name("ExpoSecureStore")
AsyncFunction("setValueWithKeyAsync") Coroutine { value: String?, key: String?, options: SecureStoreOptions ->
key ?: throw NullKeyException()
return@Coroutine setItemImpl(key, value, options, false)
}
AsyncFunction("getValueWithKeyAsync") Coroutine { key: String, options: SecureStoreOptions ->
return@Coroutine getItemImpl(key, options)
}
Function("setValueWithKeySync") { value: String?, key: String?, options: SecureStoreOptions ->
key ?: throw NullKeyException()
return@Function runBlocking {
return@runBlocking setItemImpl(key, value, options, keyIsInvalidated = false)
}
}
Function("getValueWithKeySync") { key: String, options: SecureStoreOptions ->
return@Function runBlocking {
return@runBlocking getItemImpl(key, options)
}
}
AsyncFunction("deleteValueWithKeyAsync") { key: String, options: SecureStoreOptions ->
try {
deleteItemImpl(key, options)
} catch (e: CodedException) {
throw e
} catch (e: Exception) {
throw DeleteException(e.message, key, options.keychainService, e)
}
}
Function("canUseBiometricAuthentication") {
return@Function try {
authenticationHelper.assertBiometricsSupport()
true
} catch (e: AuthenticationException) {
false
}
}
OnCreate {
authenticationHelper = AuthenticationHelper(reactContext, appContext.legacyModuleRegistry)
hybridAESEncryptor = HybridAESEncryptor(reactContext, mAESEncryptor)
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)
keyStore.load(null)
this@SecureStoreModule.keyStore = keyStore
}
}
private suspend fun getItemImpl(key: String, options: SecureStoreOptions): String? {
// 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()
val keychainAwareKey = createKeychainAwareKey(key, options.keychainService)
if (prefs.contains(keychainAwareKey)) {
return readJSONEncodedItem(key, prefs, options)
} else if (prefs.contains(key)) { // For backwards-compatibility try to read using the old key format
return readJSONEncodedItem(key, prefs, options)
}
return null
}
private suspend fun readJSONEncodedItem(key: String, prefs: SharedPreferences, options: SecureStoreOptions): String? {
val keychainAwareKey = createKeychainAwareKey(key, options.keychainService)
val legacyEncryptedItemString = prefs.getString(key, null)
val currentEncryptedItemString = prefs.getString(keychainAwareKey, null)
val encryptedItemString = currentEncryptedItemString ?: legacyEncryptedItemString
// It's not possible to efficiently remove all values from older versions of secure-store when an invalidated keychain is deleted.
// In some edge cases it will lead to read errors until the value is removed from the shared preferences
val legacyReadFailedWarning = if (currentEncryptedItemString == null) {
". This exception occurred when trying to read a value saved with an " +
"older version of `expo-secure-store`. It usually means that the keychain you provided is incorrect, " +
"but it might be raised because the keychain used to decrypt this key has been invalidated and deleted." +
" If you are confident that the keychain you provided is correct and want to avoid this error in the " +
"future you should save a new value under this key or use `deleteItemImpl()` and remove the existing one."
} else {
""
}
encryptedItemString ?: return null
val encryptedItem: JSONObject = try {
JSONObject(encryptedItemString)
} catch (e: JSONException) {
throw DecryptException("Could not parse the encrypted JSON item in SecureStore: ${e.message}", key, options.keychainService, e)
}
val scheme = encryptedItem.optString(SCHEME_PROPERTY).takeIf { it.isNotEmpty() }
?: throw DecryptException("Could not find the encryption scheme used for key: $key", key, options.keychainService)
val requireAuthentication = encryptedItem.optBoolean(AuthenticationHelper.REQUIRE_AUTHENTICATION_PROPERTY, false)
val usesKeystoreSuffix = encryptedItem.optBoolean(USES_KEYSTORE_SUFFIX_PROPERTY, false)
try {
when (scheme) {
AESEncryptor.NAME -> {
val secretKeyEntry = getKeyEntryCompat(SecretKeyEntry::class.java, mAESEncryptor, options, requireAuthentication, usesKeystoreSuffix) ?: run {
Log.w(
TAG,
"An entry was found for key $key under keychain ${options.keychainService}, but there is no corresponding KeyStore key. " +
"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 mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper)
}
HybridAESEncryptor.NAME -> {
val privateKeyEntry = getKeyEntryCompat(PrivateKeyEntry::class.java, hybridAESEncryptor, options, requireAuthentication, usesKeystoreSuffix)
?: return 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
} 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.
Log.w(
TAG,
"Failed to decrypt the entry for $key under keychain ${options.keychainService}. " +
"The entry in shared preferences is out of sync with the keystore. It will be removed, returning null."
)
deleteItemImpl(key, options)
return null
} catch (e: GeneralSecurityException) {
throw (DecryptException(e.message, key, options.keychainService, e))
} catch (e: CodedException) {
throw e
} catch (e: Exception) {
throw (DecryptException(e.message, key, options.keychainService, e))
}
}
private suspend fun setItemImpl(key: String, value: String?, options: SecureStoreOptions, keyIsInvalidated: Boolean) {
val keychainAwareKey = createKeychainAwareKey(key, options.keychainService)
val prefs: SharedPreferences = getSharedPreferences()
if (value == null) {
val success = prefs.edit().putString(keychainAwareKey, null).commit()
if (!success) {
throw WriteException("Could not write a null value to SecureStore", key, options.keychainService)
}
return
}
try {
if (keyIsInvalidated) {
// Invalidated keys will block writing even though it's not possible to re-validate them
// so we remove them before saving.
val alias = mAESEncryptor.getExtendedKeyStoreAlias(options, options.requireAuthentication)
removeKeyFromKeystore(alias, options.keychainService)
}
/* Android API 23+ supports storing symmetric keys in the keystore and on older Android
versions we store an asymmetric key pair and use hybrid encryption. We store the scheme we
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)
encryptedItem.put(SCHEME_PROPERTY, AESEncryptor.NAME)
saveEncryptedItem(encryptedItem, prefs, keychainAwareKey, options.requireAuthentication, options.keychainService)
// If a legacy value exists under this key we remove it to avoid unexpected errors in the future
if (prefs.contains(key)) {
prefs.edit().remove(key).apply()
}
} catch (e: KeyPermanentlyInvalidatedException) {
if (!keyIsInvalidated) {
Log.w(TAG, "Key has been invalidated, retrying with the key deleted")
return setItemImpl(key, value, options, true)
}
throw EncryptException("Encryption Failed. The key $key has been permanently invalidated and cannot be reinitialized", key, options.keychainService, e)
} catch (e: GeneralSecurityException) {
throw EncryptException(e.message, key, options.keychainService, e)
} catch (e: CodedException) {
throw e
} catch (e: Exception) {
throw WriteException(e.message, key, options.keychainService, e)
}
}
private fun saveEncryptedItem(encryptedItem: JSONObject, prefs: SharedPreferences, key: String, requireAuthentication: Boolean, keychainService: String): Boolean {
// We need a way to recognize entries that have been saved under an alias created with getExtendedKeychain
encryptedItem.put(USES_KEYSTORE_SUFFIX_PROPERTY, true)
// In order to be able to have the same keys under different keychains
// we need a way to recognize what is the keychain of the item when we read it
encryptedItem.put(KEYSTORE_ALIAS_PROPERTY, keychainService)
encryptedItem.put(AuthenticationHelper.REQUIRE_AUTHENTICATION_PROPERTY, requireAuthentication)
val encryptedItemString = encryptedItem.toString()
if (encryptedItemString.isNullOrEmpty()) { // JSONObject#toString() may return null
throw WriteException("Could not JSON-encode the encrypted item for SecureStore - the string $encryptedItemString is null or empty", key, keychainService)
}
return prefs.edit().putString(key, encryptedItemString).commit()
}
private fun deleteItemImpl(key: String, options: SecureStoreOptions) {
var success = true
val prefs = getSharedPreferences()
val keychainAwareKey = createKeychainAwareKey(key, options.keychainService)
val legacyPrefs = PreferenceManager.getDefaultSharedPreferences(reactContext)
if (prefs.contains(keychainAwareKey)) {
success = prefs.edit().remove(keychainAwareKey).commit()
}
if (prefs.contains(key)) {
success = prefs.edit().remove(key).commit() && success
}
if (legacyPrefs.contains(key)) {
success = legacyPrefs.edit().remove(key).commit() && success
}
if (!success) {
throw DeleteException("Could not delete the item from SecureStore", key, options.keychainService)
}
}
private fun removeKeyFromKeystore(keyStoreAlias: String, keychainService: String) {
keyStore.deleteEntry(keyStoreAlias)
removeAllEntriesUnderKeychainService(keychainService)
}
private fun removeAllEntriesUnderKeychainService(keychainService: String) {
val sharedPreferences = getSharedPreferences()
val allEntries: Map<String, *> = sharedPreferences.all
// In order to avoid decryption failures we need to remove all entries that are using the deleted encryption key
for ((key: String, value) in allEntries) {
val valueString = value as? String ?: continue
val jsonEntry = try {
JSONObject(valueString)
} catch (e: JSONException) {
continue
}
val entryKeychainService = jsonEntry.optString(KEYSTORE_ALIAS_PROPERTY) ?: continue
val requireAuthentication = jsonEntry.optBoolean(AuthenticationHelper.REQUIRE_AUTHENTICATION_PROPERTY, false)
// Entries which don't require authentication use separate keychains which can't be invalidated,
// so we shouldn't delete them.
if (requireAuthentication && keychainService == entryKeychainService) {
sharedPreferences.edit().remove(key).apply()
Log.w(TAG, "Removing entry: $key due to the encryption key being deleted")
}
}
}
/**
* Each key is stored under a keychain service that requires authentication, or one that doesn't
* Keys used to be stored under a single keychain, which led to different behaviour on iOS and Android.
* Because of that we need to check if there are any keys stored with the old secure-store key format.
*/
private fun <E : KeyStore.Entry> getLegacyKeyEntry(
keyStoreEntryClass: Class<E>,
encryptor: KeyBasedEncryptor<E>,
options: SecureStoreOptions
): E? {
val keystoreAlias = encryptor.getKeyStoreAlias(options)
if (!keyStore.containsAlias(encryptor.getKeyStoreAlias(options))) {
return null
}
val entry = keyStore.getEntry(keystoreAlias, null)
if (!keyStoreEntryClass.isInstance(entry)) {
return null
}
return keyStoreEntryClass.cast(entry)
}
private fun <E : KeyStore.Entry> getKeyEntry(
keyStoreEntryClass: Class<E>,
encryptor: KeyBasedEncryptor<E>,
options: SecureStoreOptions,
requireAuthentication: Boolean
): E? {
val keystoreAlias = encryptor.getExtendedKeyStoreAlias(options, requireAuthentication)
return if (keyStore.containsAlias(keystoreAlias)) {
val entry = keyStore.getEntry(keystoreAlias, null)
if (!keyStoreEntryClass.isInstance(entry)) {
throw KeyStoreException("The entry for the keystore alias \"$keystoreAlias\" is not a ${keyStoreEntryClass.simpleName}")
}
keyStoreEntryClass.cast(entry)
?: throw KeyStoreException("The entry for the keystore alias \"$keystoreAlias\" couldn't be cast to correct class")
} else {
null
}
}
private fun <E : KeyStore.Entry> getOrCreateKeyEntry(
keyStoreEntryClass: Class<E>,
encryptor: KeyBasedEncryptor<E>,
options: SecureStoreOptions,
requireAuthentication: Boolean
): E {
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()
}
encryptor.initializeKeyStoreEntry(keyStore, options)
}
}
private fun <E : KeyStore.Entry> getKeyEntryCompat(
keyStoreEntryClass: Class<E>,
encryptor: KeyBasedEncryptor<E>,
options: SecureStoreOptions,
requireAuthentication: Boolean,
usesKeystoreSuffix: Boolean
): E? {
return if (usesKeystoreSuffix) {
getKeyEntry(keyStoreEntryClass, encryptor, options, requireAuthentication)
} else {
getLegacyKeyEntry(keyStoreEntryClass, encryptor, options)
}
}
fun getSharedPreferences(): SharedPreferences {
return reactContext.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
}
/**
* Adds the keychain service as a prefix to the key in order to avoid conflicts in shared preferences
* when there are two identical keys but saved with different keychains.
*/
private fun createKeychainAwareKey(key: String, keychainService: String): String {
return "$keychainService-$key"
}
companion object {
const val TAG = "ExpoSecureStore"
private const val SHARED_PREFERENCES_NAME = "SecureStore"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val SCHEME_PROPERTY = "scheme"
private const val KEYSTORE_ALIAS_PROPERTY = "keystoreAlias"
const val USES_KEYSTORE_SUFFIX_PROPERTY = "usesKeystoreSuffix"
const val DEFAULT_KEYSTORE_ALIAS = "key_v1"
const val AUTHENTICATED_KEYSTORE_SUFFIX = "keystoreAuthenticated"
const val UNAUTHENTICATED_KEYSTORE_SUFFIX = "keystoreUnauthenticated"
}
}

View File

@@ -0,0 +1,12 @@
package expo.modules.securestore
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import java.io.Serializable
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
) : Record, Serializable

View File

@@ -0,0 +1,144 @@
package expo.modules.securestore.encryptors
import android.annotation.TargetApi
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import expo.modules.securestore.AuthenticationHelper
import expo.modules.securestore.DecryptException
import expo.modules.securestore.SecureStoreModule
import expo.modules.securestore.SecureStoreOptions
import org.json.JSONException
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.UnrecoverableEntryException
import java.security.spec.AlgorithmParameterSpec
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
/**
* An encryptor that stores a symmetric key (AES) in the Android keystore. It generates a new IV
* each time an item is written to prevent many-time pad attacks. The IV is stored with the
* encrypted item.
*
*
* AES with GCM is supported on Android 10+ but storing an AES key in the keystore is supported
* on only Android 23+. If you generate your own key instead of using the Android keystore (like
* the hybrid encryptor does) you can use the encryption and decryption methods of this class.
*/
class AESEncryptor : KeyBasedEncryptor<KeyStore.SecretKeyEntry> {
override fun getKeyStoreAlias(options: SecureStoreOptions): String {
val baseAlias = options.keychainService
return "$AES_CIPHER:$baseAlias"
}
/**
* Two key store entries exist for every `keychainService` passed from the JS side. This is
* because it's not possible to store unauthenticated data in authenticated key stores.
*/
override fun getExtendedKeyStoreAlias(options: SecureStoreOptions, requireAuthentication: Boolean): String {
// We aren't using requiresAuthentication from the options, because it's not a necessary option for read requests
val suffix = if (requireAuthentication) {
SecureStoreModule.AUTHENTICATED_KEYSTORE_SUFFIX
} else {
SecureStoreModule.UNAUTHENTICATED_KEYSTORE_SUFFIX
}
return "${getKeyStoreAlias(options)}:$suffix"
}
@TargetApi(23)
@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 algorithmSpec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(extendedKeystoreAlias, keyPurposes)
.setKeySize(AES_KEY_SIZE_BITS)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(options.requireAuthentication)
.build()
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider)
keyGenerator.init(algorithmSpec)
// KeyGenParameterSpec stores the key when it is generated
keyGenerator.generateKey()
return keyStore.getEntry(extendedKeystoreAlias, null) as? KeyStore.SecretKeyEntry
?: throw UnrecoverableEntryException("Could not retrieve the newly generated secret key entry")
}
@Throws(IllegalBlockSizeException::class, GeneralSecurityException::class)
override suspend fun createEncryptedItem(
plaintextValue: String,
keyStoreEntry: KeyStore.SecretKeyEntry,
requireAuthentication: Boolean,
authenticationPrompt: String,
authenticationHelper: AuthenticationHelper
): 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)
return createEncryptedItemWithCipher(plaintextValue, authenticatedCipher, gcmSpec)
}
internal fun createEncryptedItemWithCipher(
plaintextValue: String,
cipher: Cipher,
gcmSpec: GCMParameterSpec
): JSONObject {
val plaintextBytes = plaintextValue.toByteArray(StandardCharsets.UTF_8)
val ciphertextBytes = cipher.doFinal(plaintextBytes)
val ciphertext = Base64.encodeToString(ciphertextBytes, Base64.NO_WRAP)
val ivString = Base64.encodeToString(gcmSpec.iv, Base64.NO_WRAP)
val authenticationTagLength = gcmSpec.tLen
return JSONObject()
.put(CIPHERTEXT_PROPERTY, ciphertext)
.put(IV_PROPERTY, ivString)
.put(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY, authenticationTagLength)
}
@Throws(GeneralSecurityException::class, JSONException::class)
override suspend fun decryptItem(
key: String,
encryptedItem: JSONObject,
keyStoreEntry: KeyStore.SecretKeyEntry,
options: SecureStoreOptions,
authenticationHelper: AuthenticationHelper
): String {
val ciphertext = encryptedItem.getString(CIPHERTEXT_PROPERTY)
val ivString = encryptedItem.getString(IV_PROPERTY)
val authenticationTagLength = encryptedItem.getInt(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY)
val ciphertextBytes = Base64.decode(ciphertext, Base64.DEFAULT)
val ivBytes = Base64.decode(ivString, Base64.DEFAULT)
val gcmSpec = GCMParameterSpec(authenticationTagLength, ivBytes)
val cipher = Cipher.getInstance(AES_CIPHER)
val requiresAuthentication = encryptedItem.optBoolean(AuthenticationHelper.REQUIRE_AUTHENTICATION_PROPERTY)
if (authenticationTagLength < MIN_GCM_AUTHENTICATION_TAG_LENGTH) {
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)
}
companion object {
const val NAME = "aes"
const val AES_CIPHER = "AES/GCM/NoPadding"
const val AES_KEY_SIZE_BITS = 256
private const val CIPHERTEXT_PROPERTY = "ct"
const val IV_PROPERTY = "iv"
private const val GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY = "tlen"
private const val MIN_GCM_AUTHENTICATION_TAG_LENGTH = 96
}
}

View File

@@ -0,0 +1,116 @@
package expo.modules.securestore.encryptors
import android.annotation.SuppressLint
import android.content.Context
import android.security.keystore.KeyProperties
import android.util.Base64
import expo.modules.securestore.AuthenticationHelper
import expo.modules.securestore.EncryptException
import expo.modules.securestore.KeyStoreException
import expo.modules.securestore.SecureStoreModule
import expo.modules.securestore.SecureStoreOptions
import org.json.JSONException
import org.json.JSONObject
import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.SecureRandom
import java.util.*
import javax.crypto.Cipher
import javax.crypto.NoSuchPaddingException
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
/**
* An AES encryptor that works with Android L (API 22) and below, which cannot store symmetric
* keys in the keystore. We store an asymmetric key pair (RSA) in the keystore, which is used to
* securely encrypt a symmetric key (AES) that we use to encrypt the data.
*
*
* The item we store includes the ciphertext (encrypted with AES), the AES IV, and the encrypted
* symmetric key (which requires the keystore's asymmetric private key to decrypt).
*
*
* https://crypto.stackexchange.com/questions/14/how-can-i-use-asymmetric-encryption-such-as-rsa-to-encrypt-an-arbitrary-length
*
*
* When we drop support for Android API 22, we can remove the write paths but need to keep the
* read paths for phones that still have hybrid-encrypted values on disk.
*/
class HybridAESEncryptor(private var mContext: Context, private val mAESEncryptor: AESEncryptor) : KeyBasedEncryptor<KeyStore.PrivateKeyEntry> {
private val mSecureRandom: SecureRandom = SecureRandom()
override fun getExtendedKeyStoreAlias(options: SecureStoreOptions, requireAuthentication: Boolean): String {
val suffix = if (requireAuthentication) {
SecureStoreModule.AUTHENTICATED_KEYSTORE_SUFFIX
} else {
SecureStoreModule.UNAUTHENTICATED_KEYSTORE_SUFFIX
}
return "${getKeyStoreAlias(options)}:$suffix"
}
override fun getKeyStoreAlias(options: SecureStoreOptions): String {
val baseAlias = options.keychainService
return "$RSA_CIPHER:$baseAlias"
}
@Throws(GeneralSecurityException::class)
override fun initializeKeyStoreEntry(keyStore: KeyStore, options: SecureStoreOptions): KeyStore.PrivateKeyEntry {
// This should never be called after we dropped Android SDK 22 support.
throw KeyStoreException(
"Tried to initialize HybridAESEncryptor key store entry on Android SDK >= 23. This shouldn't happen. " +
"If you see this message report an issue at https://github.com/expo/expo."
)
}
@Throws(GeneralSecurityException::class, JSONException::class)
override suspend fun createEncryptedItem(
plaintextValue: String,
keyStoreEntry: KeyStore.PrivateKeyEntry,
requireAuthentication: Boolean,
authenticationPrompt: String,
authenticationHelper: AuthenticationHelper
): JSONObject {
// 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. " +
"If you see this message report an issue at https://github.com/expo/expo.",
"unknown",
"unknown"
)
}
@Throws(GeneralSecurityException::class, JSONException::class)
override suspend fun decryptItem(
key: String,
encryptedItem: JSONObject,
keyStoreEntry: KeyStore.PrivateKeyEntry,
options: SecureStoreOptions,
authenticationHelper: AuthenticationHelper
): String {
// Decrypt the encrypted symmetric key
val encryptedSecretKeyString = encryptedItem.getString(ENCRYPTED_SECRET_KEY_PROPERTY)
val encryptedSecretKeyBytes = Base64.decode(encryptedSecretKeyString, Base64.DEFAULT)
val cipher = rSACipher
cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.privateKey)
val secretKeyBytes = cipher.doFinal(encryptedSecretKeyBytes)
// constant value will be copied
@SuppressLint("InlinedApi")
val secretKey: SecretKey = SecretKeySpec(secretKeyBytes, KeyProperties.KEY_ALGORITHM_AES)
// Decrypt the value with the symmetric key
val secretKeyEntry = KeyStore.SecretKeyEntry(secretKey)
return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper)
}
@get:Throws(NoSuchAlgorithmException::class, NoSuchProviderException::class, NoSuchPaddingException::class)
private val rSACipher: Cipher
get() = Cipher.getInstance(RSA_CIPHER)
companion object {
const val NAME = "hybrid"
private const val RSA_CIPHER = "RSA/None/PKCS1Padding"
private const val ENCRYPTED_SECRET_KEY_PROPERTY = "esk"
}
}

View File

@@ -0,0 +1,39 @@
package expo.modules.securestore.encryptors
import expo.modules.securestore.AuthenticationHelper
import expo.modules.securestore.SecureStoreOptions
import org.json.JSONException
import org.json.JSONObject
import java.security.GeneralSecurityException
import java.security.KeyStore
enum class KeyPurpose {
ENCRYPT,
DECRYPT
}
interface KeyBasedEncryptor<E : KeyStore.Entry> {
fun getExtendedKeyStoreAlias(options: SecureStoreOptions, requireAuthentication: Boolean): String
fun getKeyStoreAlias(options: SecureStoreOptions): String
@Throws(GeneralSecurityException::class)
fun initializeKeyStoreEntry(keyStore: KeyStore, options: SecureStoreOptions): E
@Throws(GeneralSecurityException::class, JSONException::class)
suspend fun createEncryptedItem(
plaintextValue: String,
keyStoreEntry: E,
requireAuthentication: Boolean,
authenticationPrompt: String,
authenticationHelper: AuthenticationHelper
): JSONObject
@Throws(GeneralSecurityException::class, JSONException::class)
suspend fun decryptItem(
key: String,
encryptedItem: JSONObject,
keyStoreEntry: E,
options: SecureStoreOptions,
authenticationHelper: AuthenticationHelper
): String
}

View File

@@ -0,0 +1 @@
module.exports = require('./plugin/build/withSecureStore');

View File

@@ -0,0 +1,3 @@
declare const _default: any;
export default _default;
//# sourceMappingURL=ExpoSecureStore.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"ExpoSecureStore.d.ts","sourceRoot":"","sources":["../src/ExpoSecureStore.ts"],"names":[],"mappings":";AACA,wBAAsD"}

View File

@@ -0,0 +1,3 @@
import { requireNativeModule } from 'expo-modules-core';
export default requireNativeModule('ExpoSecureStore');
//# sourceMappingURL=ExpoSecureStore.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"ExpoSecureStore.js","sourceRoot":"","sources":["../src/ExpoSecureStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,eAAe,mBAAmB,CAAC,iBAAiB,CAAC,CAAC","sourcesContent":["import { requireNativeModule } from 'expo-modules-core';\nexport default requireNativeModule('ExpoSecureStore');\n"]}

View File

@@ -0,0 +1,3 @@
declare const _default: {};
export default _default;
//# sourceMappingURL=ExpoSecureStore.web.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"ExpoSecureStore.web.d.ts","sourceRoot":"","sources":["../src/ExpoSecureStore.web.ts"],"names":[],"mappings":";AAAA,wBAAkB"}

View File

@@ -0,0 +1,2 @@
export default {};
//# sourceMappingURL=ExpoSecureStore.web.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"ExpoSecureStore.web.js","sourceRoot":"","sources":["../src/ExpoSecureStore.web.ts"],"names":[],"mappings":"AAAA,eAAe,EAAE,CAAC","sourcesContent":["export default {};\n"]}

View File

@@ -0,0 +1,135 @@
export type KeychainAccessibilityConstant = number;
/**
* 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
* is locked.
*/
export declare const AFTER_FIRST_UNLOCK: KeychainAccessibilityConstant;
/**
* Similar to `AFTER_FIRST_UNLOCK`, except the entry is not migrated to a new device when restoring
* from a backup.
*/
export declare const AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: KeychainAccessibilityConstant;
/**
* The data in the keychain item can always be accessed regardless of whether the device is locked.
* This is the least secure option.
*/
export declare const ALWAYS: KeychainAccessibilityConstant;
/**
* Similar to `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, except the user must have set a passcode in order to
* store an entry. If the user removes their passcode, the entry will be deleted.
*/
export declare const WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: KeychainAccessibilityConstant;
/**
* Similar to `ALWAYS`, except the entry is not migrated to a new device when restoring from a backup.
*/
export declare const ALWAYS_THIS_DEVICE_ONLY: KeychainAccessibilityConstant;
/**
* The data in the keychain item can be accessed only while the device is unlocked by the user.
*/
export declare const WHEN_UNLOCKED: KeychainAccessibilityConstant;
/**
* Similar to `WHEN_UNLOCKED`, except the entry is not migrated to a new device when restoring from
* a backup.
*/
export declare const WHEN_UNLOCKED_THIS_DEVICE_ONLY: KeychainAccessibilityConstant;
export type SecureStoreOptions = {
/**
* - Android: Equivalent of the public/private key pair `Alias`.
* - iOS: The item's service, equivalent to [`kSecAttrService`](https://developer.apple.com/documentation/security/ksecattrservice/).
* > If the item is set with the `keychainService` option, it will be required to later fetch the value.
*/
keychainService?: string;
/**
* Option responsible for enabling the usage of the user authentication methods available on the device while
* accessing data stored in SecureStore.
* - Android: Equivalent to [`setUserAuthenticationRequired(true)`](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationRequired(boolean))
* (requires API 23).
* - iOS: Equivalent to [`kSecAccessControlBiometryCurrentSet`](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/ksecaccesscontrolbiometrycurrentset/).
* Complete functionality is unlocked only with a freshly generated key - this would not work in tandem with the `keychainService`
* value used for the others non-authenticated operations.
*
* Warning: This option is not supported in Expo Go when biometric authentication is available due to a missing NSFaceIDUsageDescription.
* In release builds or when using continuous native generation, make sure to use the `expo-secure-store` config plugin.
*
*/
requireAuthentication?: boolean;
/**
* Custom message displayed to the user while `requireAuthentication` option is turned on.
*/
authenticationPrompt?: string;
/**
* Specifies when the stored entry is accessible, using iOS's `kSecAttrAccessible` property.
* @see Apple's documentation on [keychain item accessibility](https://developer.apple.com/documentation/security/ksecattraccessible/).
* @default SecureStore.WHEN_UNLOCKED
* @platform ios
*/
keychainAccessible?: KeychainAccessibilityConstant;
};
/**
* Returns whether the SecureStore API is enabled on the current device. This does not check the app
* permissions.
*
* @return Promise which fulfils witch `boolean`, indicating whether the SecureStore API is available
* on the current device. Currently, this resolves `true` on Android and iOS only.
*/
export declare function isAvailableAsync(): Promise<boolean>;
/**
* Delete the value associated with the provided key.
*
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that will reject if the value couldn't be deleted.
*/
export declare function deleteItemAsync(key: string, options?: SecureStoreOptions): Promise<void>;
/**
* Reads the stored value associated with the provided key.
*
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that resolves to the previously stored value. It will return `null` if there is no entry
* for the given key or if the key has been invalidated. It will reject if an error occurs while retrieving the value.
*
* > Keys are invalidated by the system when biometrics change, such as adding a new fingerprint or changing the face profile used for face recognition.
* > 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<string | null>;
/**
* Stores a keyvalue pair.
*
* @param key The key to associate with the stored value. Keys may contain alphanumeric characters, `.`, `-`, and `_`.
* @param value The value to store. Size limit is 2048 bytes.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that will reject if value cannot be stored on the device.
*/
export declare function setItemAsync(key: string, value: string, options?: SecureStoreOptions): Promise<void>;
/**
* Stores a keyvalue 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.
*
* @param key The key to associate with the stored value. Keys may contain alphanumeric characters, `.`, `-`, and `_`.
* @param value The value to store. Size limit is 2048 bytes.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
*/
export declare function setItem(key: string, value: string, options?: SecureStoreOptions): void;
/**
* 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`
* > option set to `true` until the user authenticates.
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return Previously stored value. It will return `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;
/**
* 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`.
*/
export declare function canUseBiometricAuthentication(): boolean;
//# sourceMappingURL=SecureStore.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"SecureStore.d.ts","sourceRoot":"","sources":["../src/SecureStore.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,6BAA6B,GAAG,MAAM,CAAC;AAGnD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAE,6BAAkE,CAAC;AAGpG;;;GAGG;AACH,eAAO,MAAM,mCAAmC,EAAE,6BACG,CAAC;AAGtD;;;GAGG;AACH,eAAO,MAAM,MAAM,EAAE,6BAAsD,CAAC;AAG5E;;;GAGG;AACH,eAAO,MAAM,kCAAkC,EAAE,6BACG,CAAC;AAGrD;;GAEG;AACH,eAAO,MAAM,uBAAuB,EAAE,6BACG,CAAC;AAG1C;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,6BAA6D,CAAC;AAG1F;;;GAGG;AACH,eAAO,MAAM,8BAA8B,EAAE,6BACG,CAAC;AAKjD,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;;;;;OAYG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;OAEG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,6BAA6B,CAAC;CACpD,CAAC;AAGF;;;;;;GAMG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC,CAEzD;AAGD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAIf;AAGD;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAGxB;AAGD;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,IAAI,CAAC,CASf;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,IAAI,CAS1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,MAAM,GAAG,IAAI,CAGpF;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,IAAI,OAAO,CAEvD"}

View File

@@ -0,0 +1,176 @@
import ExpoSecureStore from './ExpoSecureStore';
// @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
* is locked.
*/
export const AFTER_FIRST_UNLOCK = ExpoSecureStore.AFTER_FIRST_UNLOCK;
// @needsAudit
/**
* Similar to `AFTER_FIRST_UNLOCK`, except the entry is not migrated to a new device when restoring
* from a backup.
*/
export const AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY = ExpoSecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
// @needsAudit
/**
* The data in the keychain item can always be accessed regardless of whether the device is locked.
* This is the least secure option.
*/
export const ALWAYS = ExpoSecureStore.ALWAYS;
// @needsAudit
/**
* Similar to `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, except the user must have set a passcode in order to
* store an entry. If the user removes their passcode, the entry will be deleted.
*/
export const WHEN_PASSCODE_SET_THIS_DEVICE_ONLY = ExpoSecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
// @needsAudit
/**
* Similar to `ALWAYS`, except the entry is not migrated to a new device when restoring from a backup.
*/
export const ALWAYS_THIS_DEVICE_ONLY = ExpoSecureStore.ALWAYS_THIS_DEVICE_ONLY;
// @needsAudit
/**
* The data in the keychain item can be accessed only while the device is unlocked by the user.
*/
export const WHEN_UNLOCKED = ExpoSecureStore.WHEN_UNLOCKED;
// @needsAudit
/**
* Similar to `WHEN_UNLOCKED`, except the entry is not migrated to a new device when restoring from
* a backup.
*/
export const WHEN_UNLOCKED_THIS_DEVICE_ONLY = ExpoSecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
const VALUE_BYTES_LIMIT = 2048;
// @needsAudit
/**
* Returns whether the SecureStore API is enabled on the current device. This does not check the app
* permissions.
*
* @return Promise which fulfils witch `boolean`, indicating whether the SecureStore API is available
* on the current device. Currently, this resolves `true` on Android and iOS only.
*/
export async function isAvailableAsync() {
return !!ExpoSecureStore.getValueWithKeyAsync;
}
// @needsAudit
/**
* Delete the value associated with the provided key.
*
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that will reject if the value couldn't be deleted.
*/
export async function deleteItemAsync(key, options = {}) {
ensureValidKey(key);
await ExpoSecureStore.deleteValueWithKeyAsync(key, options);
}
// @needsAudit
/**
* Reads the stored value associated with the provided key.
*
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that resolves to the previously stored value. It will return `null` if there is no entry
* for the given key or if the key has been invalidated. It will reject if an error occurs while retrieving the value.
*
* > Keys are invalidated by the system when biometrics change, such as adding a new fingerprint or changing the face profile used for face recognition.
* > 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(key, options = {}) {
ensureValidKey(key);
return await ExpoSecureStore.getValueWithKeyAsync(key, options);
}
// @needsAudit
/**
* Stores a keyvalue pair.
*
* @param key The key to associate with the stored value. Keys may contain alphanumeric characters, `.`, `-`, and `_`.
* @param value The value to store. Size limit is 2048 bytes.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that will reject if value cannot be stored on the device.
*/
export async function setItemAsync(key, value, options = {}) {
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.`);
}
await ExpoSecureStore.setValueWithKeyAsync(value, key, options);
}
/**
* Stores a keyvalue 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.
*
* @param key The key to associate with the stored value. Keys may contain alphanumeric characters, `.`, `-`, and `_`.
* @param value The value to store. Size limit is 2048 bytes.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
*/
export function setItem(key, value, options = {}) {
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.`);
}
return ExpoSecureStore.setValueWithKeySync(value, key, options);
}
/**
* 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`
* > option set to `true` until the user authenticates.
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return Previously stored value. It will return `null` if there is no entry for the given key or if the key has been invalidated.
*/
export function getItem(key, options = {}) {
ensureValidKey(key);
return ExpoSecureStore.getValueWithKeySync(key, options);
}
/**
* 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`.
*/
export function canUseBiometricAuthentication() {
return ExpoSecureStore.canUseBiometricAuthentication();
}
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 "_".`);
}
}
function isValidKey(key) {
return typeof key === 'string' && /^[\w.-]+$/.test(key);
}
function isValidValue(value) {
if (typeof value !== 'string') {
return false;
}
if (byteCount(value) > VALUE_BYTES_LIMIT) {
console.warn('Value being stored in SecureStore is larger than 2048 bytes and it may not be stored successfully. In a future SDK version, this call may throw an error.');
}
return true;
}
// copy-pasted from https://stackoverflow.com/a/39488643
function byteCount(value) {
let bytes = 0;
for (let i = 0; i < value.length; i++) {
const codePoint = value.charCodeAt(i);
// Lone surrogates cannot be passed to encodeURI
if (codePoint >= 0xd800 && codePoint < 0xe000) {
if (codePoint < 0xdc00 && i + 1 < value.length) {
const next = value.charCodeAt(i + 1);
if (next >= 0xdc00 && next < 0xe000) {
bytes += 4;
i++;
continue;
}
}
}
bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : 3;
}
return bytes;
}
//# sourceMappingURL=SecureStore.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["SecureStoreModule"]
},
"android": {
"modules": ["expo.modules.securestore.SecureStoreModule"]
}
}

View File

@@ -0,0 +1,32 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoSecureStore'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platform = :ios, '13.4'
s.swift_version = '5.4'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift}"
end
end

View File

@@ -0,0 +1,11 @@
import ExpoModulesCore
enum SecureStoreAccessible: Int, Enumerable {
case afterFirstUnlock = 0
case afterFirstUnlockThisDeviceOnly = 1
case always = 2
case whenPasscodeSetThisDeviceOnly = 3
case alwaysThisDeviceOnly = 4
case whenUnlocked = 5
case whenUnlockedThisDeviceOnly = 6
}

View File

@@ -0,0 +1,64 @@
import ExpoModulesCore
internal class InvalidKeyException: Exception {
override var reason: String {
"Invalid key"
}
}
internal class MissingPlistKeyException: Exception {
override var reason: String {
"You must set `NSFaceIDUsageDescription` in your Info.plist file to use the `requireAuthentication` option"
}
}
internal class KeyChainException: GenericException<OSStatus> {
override var reason: String {
switch param {
case errSecUnimplemented:
return "Function or operation not implemented."
case errSecIO:
return "I/O error."
case errSecOpWr:
return "File already open with with write permission."
case errSecParam:
return "One or more parameters passed to a function where not valid."
case errSecAllocate:
return "Failed to allocate memory."
case errSecUserCanceled:
return "User canceled the operation."
case errSecBadReq:
return "Bad parameter or invalid state for operation."
case errSecNotAvailable:
return "No keychain is available. You may need to restart your computer."
case errSecDuplicateItem:
return "The specified item already exists in the keychain."
case errSecItemNotFound:
return "The specified item could not be found in the keychain."
case errSecInteractionNotAllowed:
return "User interaction is not allowed."
case errSecDecode:
return "Unable to decode the provided data."
case errSecAuthFailed:
return "Authentication failed. Provided passphrase/PIN is incorrect or there is no user authentication method configured for this device."
default:
if let errorMessage = SecCopyErrorMessageString(param, nil) as? String {
return errorMessage
}
return "Unknown Keychain Error."
}
}
}

View File

@@ -0,0 +1,207 @@
import ExpoModulesCore
import LocalAuthentication
import Security
public final class SecureStoreModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoSecureStore")
Constants([
"AFTER_FIRST_UNLOCK": SecureStoreAccessible.afterFirstUnlock.rawValue,
"AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY": SecureStoreAccessible.afterFirstUnlockThisDeviceOnly.rawValue,
"ALWAYS": SecureStoreAccessible.always.rawValue,
"WHEN_PASSCODE_SET_THIS_DEVICE_ONLY": SecureStoreAccessible.whenPasscodeSetThisDeviceOnly.rawValue,
"ALWAYS_THIS_DEVICE_ONLY": SecureStoreAccessible.alwaysThisDeviceOnly.rawValue,
"WHEN_UNLOCKED": SecureStoreAccessible.whenUnlocked.rawValue,
"WHEN_UNLOCKED_THIS_DEVICE_ONLY": SecureStoreAccessible.whenUnlockedThisDeviceOnly.rawValue
])
AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in
return try get(with: key, options: options)
}
Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in
return try get(with: key, options: options)
}
AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in
guard let key = validate(for: key) else {
throw InvalidKeyException()
}
return try set(value: value, with: key, options: options)
}
Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in
guard let key = validate(for: key) else {
throw InvalidKeyException()
}
return try set(value: value, with: key, options: options)
}
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)
SecItemDelete(legacySearchDictionary as CFDictionary)
SecItemDelete(authSearchDictionary as CFDictionary)
SecItemDelete(noAuthSearchDictionary as CFDictionary)
}
Function("canUseBiometricAuthentication") {() -> Bool in
let context = LAContext()
var error: NSError?
let isBiometricsSupported: Bool = context.canEvaluatePolicy(LAPolicy.deviceOwnerAuthenticationWithBiometrics, error: &error)
if error != nil {
return false
}
return isBiometricsSupported
}
}
private func get(with key: String, options: SecureStoreOptions) throws -> String? {
guard let key = validate(for: key) else {
throw InvalidKeyException()
}
if let unauthenticatedItem = try searchKeyChain(with: key, options: options, requireAuthentication: false) {
return String(data: unauthenticatedItem, encoding: .utf8)
}
if let authenticatedItem = try searchKeyChain(with: key, options: options, requireAuthentication: true) {
return String(data: authenticatedItem, encoding: .utf8)
}
if let legacyItem = try searchKeyChain(with: key, options: options) {
return String(data: legacyItem, encoding: .utf8)
}
return nil
}
private func set(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
var setItemQuery = query(with: key, options: options, requireAuthentication: options.requireAuthentication)
let valueData = value.data(using: .utf8)
setItemQuery[kSecValueData as String] = valueData
let accessibility = attributeWith(options: options)
if !options.requireAuthentication {
setItemQuery[kSecAttrAccessible as String] = accessibility
} else {
guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else {
throw MissingPlistKeyException()
}
let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, SecAccessControlCreateFlags.biometryCurrentSet, nil)
setItemQuery[kSecAttrAccessControl as String] = accessOptions
}
let status = SecItemAdd(setItemQuery as CFDictionary, nil)
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)
return true
case errSecDuplicateItem:
return try update(value: value, with: key, options: options)
default:
throw KeyChainException(status)
}
}
private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
var query = 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 {
return true
} else {
throw KeyChainException(status)
}
}
private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? {
var query = 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)
switch status {
case errSecSuccess:
guard let item = item as? Data else {
return nil
}
return item
case errSecItemNotFound:
return nil
default:
throw KeyChainException(status)
}
}
private func query(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) -> [String: Any] {
var service = options.keychainService ?? "app"
if let requireAuthentication {
service.append(":\(requireAuthentication ? "auth" : "no-auth")")
}
let encodedKey = Data(key.utf8)
return [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrGeneric as String: encodedKey,
kSecAttrAccount as String: encodedKey
]
}
private func attributeWith(options: SecureStoreOptions) -> CFString {
switch options.keychainAccessible {
case .afterFirstUnlock:
return kSecAttrAccessibleAfterFirstUnlock
case .afterFirstUnlockThisDeviceOnly:
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
case .always:
return kSecAttrAccessibleAlways
case .whenPasscodeSetThisDeviceOnly:
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
case .whenUnlocked:
return kSecAttrAccessibleWhenUnlocked
case .alwaysThisDeviceOnly:
return kSecAttrAccessibleAlwaysThisDeviceOnly
case .whenUnlockedThisDeviceOnly:
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
default:
return kSecAttrAccessibleWhenUnlocked
}
}
private func validate(for key: String) -> String? {
let trimmedKey = key.trimmingCharacters(in: .whitespaces)
if trimmedKey.isEmpty {
return nil
}
return key
}
}

View File

@@ -0,0 +1,15 @@
import ExpoModulesCore
internal struct SecureStoreOptions: Record {
@Field
var authenticationPrompt: String?
@Field
var keychainAccessible: SecureStoreAccessible = .whenUnlocked
@Field
var keychainService: String?
@Field
var requireAuthentication: Bool
}

View File

@@ -0,0 +1,46 @@
{
"name": "expo-secure-store",
"version": "13.0.2",
"description": "Provides a way to encrypt and securely store key-value pairs locally on the device.",
"main": "build/SecureStore.js",
"types": "build/SecureStore.d.ts",
"sideEffects": false,
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"keywords": [
"react-native",
"expo",
"expo-secure-store",
"secure",
"store"
],
"repository": {
"type": "git",
"url": "https://github.com/expo/expo.git",
"directory": "packages/expo-secure-store"
},
"bugs": {
"url": "https://github.com/expo/expo/issues"
},
"author": "650 Industries, Inc.",
"license": "MIT",
"homepage": "https://docs.expo.dev/versions/latest/sdk/securestore/",
"jest": {
"preset": "expo-module-scripts"
},
"dependencies": {},
"devDependencies": {
"expo-module-scripts": "^3.0.0"
},
"peerDependencies": {
"expo": "*"
},
"gitHead": "09b2d97bbc0f70f7c811ff9b6c9ad8808c5ad84b"
}

View File

@@ -0,0 +1,5 @@
import { ConfigPlugin } from '@expo/config-plugins';
declare const _default: ConfigPlugin<void | {
faceIDPermission?: string | false | undefined;
}>;
export default _default;

View File

@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const config_plugins_1 = require("@expo/config-plugins");
const pkg = require('expo-secure-store/package.json');
const FACEID_USAGE = 'Allow $(PRODUCT_NAME) to access your Face ID biometric data.';
const withSecureStore = (config, { faceIDPermission } = {}) => {
return config_plugins_1.IOSConfig.Permissions.createPermissionsPlugin({
NSFaceIDUsageDescription: FACEID_USAGE,
})(config, {
NSFaceIDUsageDescription: faceIDPermission,
});
};
exports.default = (0, config_plugins_1.createRunOncePlugin)(withSecureStore, pkg.name, pkg.version);

View File

@@ -0,0 +1 @@
module.exports = require('expo-module-scripts/jest-preset-plugin');

View File

@@ -0,0 +1,19 @@
import { ConfigPlugin, IOSConfig, createRunOncePlugin } from '@expo/config-plugins';
const pkg = require('expo-secure-store/package.json');
const FACEID_USAGE = 'Allow $(PRODUCT_NAME) to access your Face ID biometric data.';
const withSecureStore: ConfigPlugin<
{
faceIDPermission?: string | false;
} | void
> = (config, { faceIDPermission } = {}) => {
return IOSConfig.Permissions.createPermissionsPlugin({
NSFaceIDUsageDescription: FACEID_USAGE,
})(config, {
NSFaceIDUsageDescription: faceIDPermission,
});
};
export default createRunOncePlugin(withSecureStore, pkg.name, pkg.version);

View File

@@ -0,0 +1,9 @@
{
"extends": "expo-module-scripts/tsconfig.plugin",
"compilerOptions": {
"outDir": "build",
"rootDir": "src"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}

View File

@@ -0,0 +1,2 @@
import { requireNativeModule } from 'expo-modules-core';
export default requireNativeModule('ExpoSecureStore');

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1,261 @@
import ExpoSecureStore from './ExpoSecureStore';
export type KeychainAccessibilityConstant = number;
// @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
* is locked.
*/
export const AFTER_FIRST_UNLOCK: KeychainAccessibilityConstant = ExpoSecureStore.AFTER_FIRST_UNLOCK;
// @needsAudit
/**
* Similar to `AFTER_FIRST_UNLOCK`, except the entry is not migrated to a new device when restoring
* from a backup.
*/
export const AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
ExpoSecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
// @needsAudit
/**
* The data in the keychain item can always be accessed regardless of whether the device is locked.
* This is the least secure option.
*/
export const ALWAYS: KeychainAccessibilityConstant = ExpoSecureStore.ALWAYS;
// @needsAudit
/**
* Similar to `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, except the user must have set a passcode in order to
* store an entry. If the user removes their passcode, the entry will be deleted.
*/
export const WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
ExpoSecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY;
// @needsAudit
/**
* Similar to `ALWAYS`, except the entry is not migrated to a new device when restoring from a backup.
*/
export const ALWAYS_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
ExpoSecureStore.ALWAYS_THIS_DEVICE_ONLY;
// @needsAudit
/**
* The data in the keychain item can be accessed only while the device is unlocked by the user.
*/
export const WHEN_UNLOCKED: KeychainAccessibilityConstant = ExpoSecureStore.WHEN_UNLOCKED;
// @needsAudit
/**
* Similar to `WHEN_UNLOCKED`, except the entry is not migrated to a new device when restoring from
* a backup.
*/
export const WHEN_UNLOCKED_THIS_DEVICE_ONLY: KeychainAccessibilityConstant =
ExpoSecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
const VALUE_BYTES_LIMIT = 2048;
// @needsAudit
export type SecureStoreOptions = {
/**
* - Android: Equivalent of the public/private key pair `Alias`.
* - iOS: The item's service, equivalent to [`kSecAttrService`](https://developer.apple.com/documentation/security/ksecattrservice/).
* > If the item is set with the `keychainService` option, it will be required to later fetch the value.
*/
keychainService?: string;
/**
* Option responsible for enabling the usage of the user authentication methods available on the device while
* accessing data stored in SecureStore.
* - Android: Equivalent to [`setUserAuthenticationRequired(true)`](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setUserAuthenticationRequired(boolean))
* (requires API 23).
* - iOS: Equivalent to [`kSecAccessControlBiometryCurrentSet`](https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/ksecaccesscontrolbiometrycurrentset/).
* Complete functionality is unlocked only with a freshly generated key - this would not work in tandem with the `keychainService`
* value used for the others non-authenticated operations.
*
* Warning: This option is not supported in Expo Go when biometric authentication is available due to a missing NSFaceIDUsageDescription.
* In release builds or when using continuous native generation, make sure to use the `expo-secure-store` config plugin.
*
*/
requireAuthentication?: boolean;
/**
* Custom message displayed to the user while `requireAuthentication` option is turned on.
*/
authenticationPrompt?: string;
/**
* Specifies when the stored entry is accessible, using iOS's `kSecAttrAccessible` property.
* @see Apple's documentation on [keychain item accessibility](https://developer.apple.com/documentation/security/ksecattraccessible/).
* @default SecureStore.WHEN_UNLOCKED
* @platform ios
*/
keychainAccessible?: KeychainAccessibilityConstant;
};
// @needsAudit
/**
* Returns whether the SecureStore API is enabled on the current device. This does not check the app
* permissions.
*
* @return Promise which fulfils witch `boolean`, indicating whether the SecureStore API is available
* on the current device. Currently, this resolves `true` on Android and iOS only.
*/
export async function isAvailableAsync(): Promise<boolean> {
return !!ExpoSecureStore.getValueWithKeyAsync;
}
// @needsAudit
/**
* Delete the value associated with the provided key.
*
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that will reject if the value couldn't be deleted.
*/
export async function deleteItemAsync(
key: string,
options: SecureStoreOptions = {}
): Promise<void> {
ensureValidKey(key);
await ExpoSecureStore.deleteValueWithKeyAsync(key, options);
}
// @needsAudit
/**
* Reads the stored value associated with the provided key.
*
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that resolves to the previously stored value. It will return `null` if there is no entry
* for the given key or if the key has been invalidated. It will reject if an error occurs while retrieving the value.
*
* > Keys are invalidated by the system when biometrics change, such as adding a new fingerprint or changing the face profile used for face recognition.
* > 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(
key: string,
options: SecureStoreOptions = {}
): Promise<string | null> {
ensureValidKey(key);
return await ExpoSecureStore.getValueWithKeyAsync(key, options);
}
// @needsAudit
/**
* Stores a keyvalue pair.
*
* @param key The key to associate with the stored value. Keys may contain alphanumeric characters, `.`, `-`, and `_`.
* @param value The value to store. Size limit is 2048 bytes.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return A promise that will reject if value cannot be stored on the device.
*/
export async function setItemAsync(
key: string,
value: string,
options: SecureStoreOptions = {}
): Promise<void> {
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.`
);
}
await ExpoSecureStore.setValueWithKeyAsync(value, key, options);
}
/**
* Stores a keyvalue 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.
*
* @param key The key to associate with the stored value. Keys may contain alphanumeric characters, `.`, `-`, and `_`.
* @param value The value to store. Size limit is 2048 bytes.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
*/
export function setItem(key: string, value: string, options: SecureStoreOptions = {}): void {
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.`
);
}
return ExpoSecureStore.setValueWithKeySync(value, key, options);
}
/**
* 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`
* > option set to `true` until the user authenticates.
* @param key The key that was used to store the associated value.
* @param options An [`SecureStoreOptions`](#securestoreoptions) object.
*
* @return Previously stored value. It will return `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 {
ensureValidKey(key);
return ExpoSecureStore.getValueWithKeySync(key, options);
}
/**
* 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`.
*/
export function canUseBiometricAuthentication(): boolean {
return ExpoSecureStore.canUseBiometricAuthentication();
}
function ensureValidKey(key: string) {
if (!isValidKey(key)) {
throw new Error(
`Invalid key provided to SecureStore. Keys must not be empty and contain only alphanumeric characters, ".", "-", and "_".`
);
}
}
function isValidKey(key: string) {
return typeof key === 'string' && /^[\w.-]+$/.test(key);
}
function isValidValue(value: string) {
if (typeof value !== 'string') {
return false;
}
if (byteCount(value) > VALUE_BYTES_LIMIT) {
console.warn(
'Value being stored in SecureStore is larger than 2048 bytes and it may not be stored successfully. In a future SDK version, this call may throw an error.'
);
}
return true;
}
// copy-pasted from https://stackoverflow.com/a/39488643
function byteCount(value: string) {
let bytes = 0;
for (let i = 0; i < value.length; i++) {
const codePoint = value.charCodeAt(i);
// Lone surrogates cannot be passed to encodeURI
if (codePoint >= 0xd800 && codePoint < 0xe000) {
if (codePoint < 0xdc00 && i + 1 < value.length) {
const next = value.charCodeAt(i + 1);
if (next >= 0xdc00 && next < 0xe000) {
bytes += 4;
i++;
continue;
}
}
}
bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : 3;
}
return bytes;
}

View File

@@ -0,0 +1,9 @@
// @generated by expo-module-scripts
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}