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 - createExpoSecureStoreSupplier(@NonNull Context context) { - return () -> { - List 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 { + 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 { + 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 = 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 getLegacyKeyEntry( + keyStoreEntryClass: Class, + encryptor: KeyBasedEncryptor, + 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 getKeyEntry( + keyStoreEntryClass: Class, + encryptor: KeyBasedEncryptor, + 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 getOrCreateKeyEntry( + keyStoreEntryClass: Class, + encryptor: KeyBasedEncryptor, + 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 getKeyEntryCompat( + keyStoreEntryClass: Class, + encryptor: KeyBasedEncryptor, + 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