Page MenuHomePhorge

D14975.1765054879.diff
No OneTemporary

Size
38 KB
Referenced Files
None
Subscribers
None

D14975.1765054879.diff

diff --git a/native/android/app/build.gradle b/native/android/app/build.gradle
--- a/native/android/app/build.gradle
+++ b/native/android/app/build.gradle
@@ -424,6 +424,8 @@
// https://stackoverflow.com/questions/64290141/android-studio-class-file-for-com-google-common-util-concurrent-listenablefuture
// https://github.com/google/ExoPlayer/issues/7993
implementation "com.google.guava:guava:31.0.1-android"
+
+ api "androidx.biometric:biometric:1.1.0"
}
apply plugin: 'com.google.gms.google-services'
diff --git a/native/android/app/src/main/java/app/comm/android/ExpoUtils.java b/native/android/app/src/main/java/app/comm/android/ExpoUtils.java
deleted file mode 100644
--- a/native/android/app/src/main/java/app/comm/android/ExpoUtils.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package app.comm.android;
-
-import android.content.Context;
-import androidx.annotation.NonNull;
-import com.facebook.react.bridge.ReactContext;
-import expo.modules.adapters.react.services.UIManagerModuleWrapper;
-import expo.modules.core.ModuleRegistry;
-import expo.modules.core.interfaces.InternalModule;
-import expo.modules.securestore.SecureStoreModule;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.function.Supplier;
-
-public class ExpoUtils {
- public static Supplier<SecureStoreModule>
- createExpoSecureStoreSupplier(@NonNull Context context) {
- return () -> {
- List<InternalModule> expoInternalModules = new ArrayList<>(1);
- if (context instanceof ReactContext) {
- // We can only provide the UIManager module if provided context is a
- // React context. If this is called from non-react activity, like
- // CommNotificationsHandler, we skip adding this module. This is fine,
- // unless we use the `requiresAuthentication` option when dealing with
- // expo-secure-store (at this moment we don't use it anywhere)
- expoInternalModules.add(
- new UIManagerModuleWrapper((ReactContext)context));
- }
-
- ModuleRegistry expoModuleRegistry = new ModuleRegistry(
- expoInternalModules,
- Collections.emptyList(),
- Collections.emptyList(),
- Collections.emptyList());
- SecureStoreModule secureStoreModule = new SecureStoreModule(context);
- secureStoreModule.onCreate(expoModuleRegistry);
- return secureStoreModule;
- };
- }
-}
diff --git a/native/android/app/src/main/java/app/comm/android/fbjni/CommSecureStore.java b/native/android/app/src/main/java/app/comm/android/fbjni/CommSecureStore.java
--- a/native/android/app/src/main/java/app/comm/android/fbjni/CommSecureStore.java
+++ b/native/android/app/src/main/java/app/comm/android/fbjni/CommSecureStore.java
@@ -1,20 +1,13 @@
package app.comm.android.fbjni;
+import android.util.Log;
+import app.comm.android.securestore.SecureStoreModule;
import expo.modules.core.Promise;
-import expo.modules.core.arguments.MapArguments;
-import expo.modules.core.arguments.ReadableArguments;
-import expo.modules.securestore.SecureStoreModule;
import java.util.function.Supplier;
public class CommSecureStore {
-
private static final CommSecureStore instance = new CommSecureStore();
private SecureStoreModule secureStoreModule = null;
- private final ReadableArguments readableArguments;
-
- private CommSecureStore() {
- this.readableArguments = new MapArguments();
- }
public static CommSecureStore getInstance() {
return CommSecureStore.instance;
@@ -50,8 +43,7 @@
throw new RuntimeException("secure store set error: " + message);
}
};
- this.secureStoreModule.setValueWithKeyAsync(
- value, key, this.readableArguments, promise);
+ this.secureStoreModule.setValueWithKey(key, value, promise);
}
private String internalGet(String key) {
@@ -70,9 +62,7 @@
}
};
// The following call will resolve the promise before it returns
- this.secureStoreModule.getValueWithKeyAsync(
- key, this.readableArguments, promise);
-
+ this.secureStoreModule.getValueWithKey(key, promise);
return result[0];
}
diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java
--- a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java
+++ b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java
@@ -15,7 +15,6 @@
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.ProcessLifecycleOwner;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import app.comm.android.ExpoUtils;
import app.comm.android.MainActivity;
import app.comm.android.R;
import app.comm.android.aescrypto.AESCryptoModuleCompat;
@@ -28,6 +27,7 @@
import app.comm.android.fbjni.NotificationsCryptoModule;
import app.comm.android.fbjni.StaffUtils;
import app.comm.android.fbjni.ThreadOperations;
+import app.comm.android.securestore.SecureStoreModule;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.io.File;
@@ -88,7 +88,7 @@
public void onCreate() {
super.onCreate();
CommSecureStore.getInstance().initialize(
- ExpoUtils.createExpoSecureStoreSupplier(this.getApplicationContext()));
+ () -> new SecureStoreModule(this.getApplicationContext()));
notificationManager = (NotificationManager)this.getSystemService(
Context.NOTIFICATION_SERVICE);
localBroadcastManager = LocalBroadcastManager.getInstance(this);
diff --git a/native/android/app/src/main/java/app/comm/android/securestore/AESEncryptor.kt b/native/android/app/src/main/java/app/comm/android/securestore/AESEncryptor.kt
new file mode 100644
--- /dev/null
+++ b/native/android/app/src/main/java/app/comm/android/securestore/AESEncryptor.kt
@@ -0,0 +1,153 @@
+/**
+ File copied from expo-secure-store 14.x
+ https://github.com/expo/expo/blob/49c9d53cf0a9fc8179d1c8f5268beadd141f70ca/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt
+
+ Why we copy: https://linear.app/comm/issue/ENG-10284/migrate-expo-secure-store-related-code
+*/
+package app.comm.android.securestore
+
+import android.annotation.TargetApi
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+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
+ }
+}
\ No newline at end of file
diff --git a/native/android/app/src/main/java/app/comm/android/securestore/AuthenticationHelper.kt b/native/android/app/src/main/java/app/comm/android/securestore/AuthenticationHelper.kt
new file mode 100644
--- /dev/null
+++ b/native/android/app/src/main/java/app/comm/android/securestore/AuthenticationHelper.kt
@@ -0,0 +1,90 @@
+/**
+ File copied from expo-secure-store 14.x
+ https://github.com/expo/expo/blob/49c9d53cf0a9fc8179d1c8f5268beadd141f70ca/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt
+
+ Why we copy: https://linear.app/comm/issue/ENG-10284/migrate-expo-secure-store-related-code
+*/
+package app.comm.android.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 com.facebook.react.bridge.ReactContext
+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 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 = (context as? ReactContext)?.currentActivity 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")
+ }
+ }
+ }
+
+ companion object {
+ const val REQUIRE_AUTHENTICATION_PROPERTY = "requireAuthentication"
+ }
+}
\ No newline at end of file
diff --git a/native/android/app/src/main/java/app/comm/android/securestore/AuthenticationPrompt.kt b/native/android/app/src/main/java/app/comm/android/securestore/AuthenticationPrompt.kt
new file mode 100644
--- /dev/null
+++ b/native/android/app/src/main/java/app/comm/android/securestore/AuthenticationPrompt.kt
@@ -0,0 +1,50 @@
+/**
+ File copied from expo-secure-store 14.x
+ https://github.com/expo/expo/blob/49c9d53cf0a9fc8179d1c8f5268beadd141f70ca/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt
+
+ Why we copy: https://linear.app/comm/issue/ENG-10284/migrate-expo-secure-store-related-code
+*/
+package app.comm.android.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))
+ }
+}
\ No newline at end of file
diff --git a/native/android/app/src/main/java/app/comm/android/securestore/KeyBasedEncryptor.kt b/native/android/app/src/main/java/app/comm/android/securestore/KeyBasedEncryptor.kt
new file mode 100644
--- /dev/null
+++ b/native/android/app/src/main/java/app/comm/android/securestore/KeyBasedEncryptor.kt
@@ -0,0 +1,43 @@
+/**
+ File copied from expo-secure-store 14.x
+ https://github.com/expo/expo/blob/49c9d53cf0a9fc8179d1c8f5268beadd141f70ca/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt
+
+ Why we copy: https://linear.app/comm/issue/ENG-10284/migrate-expo-secure-store-related-code
+*/
+package app.comm.android.securestore
+
+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
+}
\ No newline at end of file
diff --git a/native/android/app/src/main/java/app/comm/android/securestore/SecureStoreModule.kt b/native/android/app/src/main/java/app/comm/android/securestore/SecureStoreModule.kt
new file mode 100644
--- /dev/null
+++ b/native/android/app/src/main/java/app/comm/android/securestore/SecureStoreModule.kt
@@ -0,0 +1,376 @@
+/**
+ File copied from expo-secure-store 14.x with some changes
+ https://github.com/expo/expo/blob/49c9d53cf0a9fc8179d1c8f5268beadd141f70ca/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt
+
+ Why we copy: https://linear.app/comm/issue/ENG-10284/migrate-expo-secure-store-related-code
+*/
+package app.comm.android.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.core.Promise
+import expo.modules.kotlin.exception.CodedException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.runBlocking
+import org.json.JSONException
+import org.json.JSONObject
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import java.security.KeyStore.SecretKeyEntry
+import javax.crypto.BadPaddingException
+
+data class SecureStoreOptions(
+ var authenticationPrompt: String = " ",
+ var keychainService: String = SecureStoreModule.DEFAULT_KEYSTORE_ALIAS,
+ var requireAuthentication: Boolean = false
+)
+
+class SecureStoreModule(private var context: Context) {
+ private val mAESEncryptor = AESEncryptor()
+ private var keyStore: KeyStore
+ private var authenticationHelper: AuthenticationHelper = AuthenticationHelper(context)
+ private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ init {
+ val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)
+ keyStore.load(null)
+ this@SecureStoreModule.keyStore = keyStore
+ }
+
+ fun setValueWithKey(key: String, value: String?, promise: Promise) {
+ runBlocking {
+ setItemImpl(key, value, SecureStoreOptions(), false)
+ promise.resolve(Unit)
+ }
+ }
+
+ fun getValueWithKey(key: String, promise: Promise) {
+ runBlocking {
+ val result = getItemImpl(key, SecureStoreOptions())
+ promise.resolve(result)
+ }
+ }
+
+ 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)
+ }
+ 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(context)
+
+ 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)
+ }
+ }
+
+ private fun getSharedPreferences(): SharedPreferences {
+ return context.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"
+ }
+}
+
+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"}")
\ No newline at end of file

File Metadata

Mime Type
text/plain
Expires
Sat, Dec 6, 9:01 PM (22 h, 22 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5840832
Default Alt Text
D14975.1765054879.diff (38 KB)

Event Timeline