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,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
}