expo-secure-store 12.4.1 → 12.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/android/build.gradle +56 -34
  3. package/android/src/main/java/expo/modules/securestore/AuthenticationHelper.kt +48 -171
  4. package/android/src/main/java/expo/modules/securestore/AuthenticationPrompt.kt +44 -0
  5. package/android/src/main/java/expo/modules/securestore/SecureStoreExceptions.kt +13 -16
  6. package/android/src/main/java/expo/modules/securestore/SecureStoreModule.kt +352 -0
  7. package/android/src/main/java/expo/modules/securestore/SecureStoreOptions.kt +12 -0
  8. package/android/src/main/java/expo/modules/securestore/encryptors/AESEncryptor.kt +138 -0
  9. package/android/src/main/java/expo/modules/securestore/encryptors/HybridAESEncryptor.kt +107 -0
  10. package/android/src/main/java/expo/modules/securestore/encryptors/KeyBasedEncryptor.kt +38 -0
  11. package/build/SecureStore.d.ts +23 -4
  12. package/build/SecureStore.d.ts.map +1 -1
  13. package/build/SecureStore.js +42 -21
  14. package/build/SecureStore.js.map +1 -1
  15. package/expo-module.config.json +3 -0
  16. package/ios/SecureStoreModule.swift +1 -1
  17. package/package.json +2 -2
  18. package/src/SecureStore.ts +48 -22
  19. package/android/src/main/java/expo/modules/securestore/AuthenticationCallback.kt +0 -28
  20. package/android/src/main/java/expo/modules/securestore/EncryptionCallback.kt +0 -18
  21. package/android/src/main/java/expo/modules/securestore/PostEncryptionCallback.kt +0 -11
  22. package/android/src/main/java/expo/modules/securestore/SecureStoreModule.java +0 -692
  23. package/android/src/main/java/expo/modules/securestore/SecureStorePackage.java +0 -16
package/CHANGELOG.md CHANGED
@@ -10,6 +10,24 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 12.6.0 — 2023-10-17
14
+
15
+ ### 🛠 Breaking changes
16
+
17
+ - Dropped support for Android SDK 21 and 22. ([#24201](https://github.com/expo/expo/pull/24201) by [@behenate](https://github.com/behenate))
18
+
19
+ ### 🐛 Bug fixes
20
+
21
+ - Fixed the 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' constraint being incorrectly mapped to wrong secure store accessible ([#24831](https://github.com/expo/expo/pull/24831) by [@mmmguitar](https://github.com/mmmguitar))
22
+
23
+ ## 12.5.0 — 2023-09-04
24
+
25
+ ### 🎉 New features
26
+
27
+ - [Android] Migrated to Expo Modules API. ([#23804](https://github.com/expo/expo/pull/23804) by [@behenate](https://github.com/behenate))
28
+ - [Android] It is now possible to store values that require authentication and ones that don't under the same `keychainService`. ([#23804](https://github.com/expo/expo/pull/23804) by [@behenate](https://github.com/behenate))
29
+ - Added support for React Native 0.73. ([#24018](https://github.com/expo/expo/pull/24018) by [@kudo](https://github.com/kudo))
30
+
13
31
  ## 12.4.1 — 2023-08-02
14
32
 
15
33
  _This version does not introduce any user-facing changes._
@@ -3,15 +3,20 @@ apply plugin: 'kotlin-android'
3
3
  apply plugin: 'maven-publish'
4
4
 
5
5
  group = 'host.exp.exponent'
6
- version = '12.4.1'
6
+ version = '12.6.0'
7
7
 
8
- buildscript {
9
- def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
10
- if (expoModulesCorePlugin.exists()) {
11
- apply from: expoModulesCorePlugin
12
- applyKotlinExpoModulesCorePlugin()
8
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
9
+ if (expoModulesCorePlugin.exists()) {
10
+ apply from: expoModulesCorePlugin
11
+ applyKotlinExpoModulesCorePlugin()
12
+ // Remove this check, but keep the contents after SDK49 support is dropped
13
+ if (safeExtGet("expoProvidesDefaultConfig", false)) {
14
+ useExpoPublishing()
15
+ useCoreDependencies()
13
16
  }
17
+ }
14
18
 
19
+ buildscript {
15
20
  // Simple helper that allows the root project to override versions declared by this library.
16
21
  ext.safeExtGet = { prop, fallback ->
17
22
  rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
@@ -35,54 +40,71 @@ buildscript {
35
40
  }
36
41
  }
37
42
 
38
- afterEvaluate {
39
- publishing {
40
- publications {
41
- release(MavenPublication) {
42
- from components.release
43
+ // Remove this if and it's contents, when support for SDK49 is dropped
44
+ if (!safeExtGet("expoProvidesDefaultConfig", false)) {
45
+ afterEvaluate {
46
+ publishing {
47
+ publications {
48
+ release(MavenPublication) {
49
+ from components.release
50
+ }
43
51
  }
44
- }
45
- repositories {
46
- maven {
47
- url = mavenLocal().url
52
+ repositories {
53
+ maven {
54
+ url = mavenLocal().url
55
+ }
48
56
  }
49
57
  }
50
58
  }
51
59
  }
52
60
 
53
61
  android {
54
- compileSdkVersion safeExtGet("compileSdkVersion", 33)
62
+ // Remove this if and it's contents, when support for SDK49 is dropped
63
+ if (!safeExtGet("expoProvidesDefaultConfig", false)) {
64
+ compileSdkVersion safeExtGet("compileSdkVersion", 33)
65
+
66
+ defaultConfig {
67
+ minSdkVersion safeExtGet("minSdkVersion", 23)
68
+ targetSdkVersion safeExtGet("targetSdkVersion", 33)
69
+ }
70
+
71
+ publishing {
72
+ singleVariant("release") {
73
+ withSourcesJar()
74
+ }
75
+ }
55
76
 
56
- compileOptions {
57
- sourceCompatibility JavaVersion.VERSION_11
58
- targetCompatibility JavaVersion.VERSION_11
77
+ lintOptions {
78
+ abortOnError false
79
+ }
59
80
  }
60
81
 
61
- kotlinOptions {
62
- jvmTarget = JavaVersion.VERSION_11.majorVersion
82
+ def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
83
+ if (agpVersion.tokenize('.')[0].toInteger() < 8) {
84
+ compileOptions {
85
+ sourceCompatibility JavaVersion.VERSION_11
86
+ targetCompatibility JavaVersion.VERSION_11
87
+ }
88
+
89
+ kotlinOptions {
90
+ jvmTarget = JavaVersion.VERSION_11.majorVersion
91
+ }
63
92
  }
64
93
 
65
94
  namespace "expo.modules.securestore"
66
95
  defaultConfig {
67
- minSdkVersion safeExtGet("minSdkVersion", 21)
68
- targetSdkVersion safeExtGet("targetSdkVersion", 33)
69
96
  versionCode 17
70
- versionName '12.4.1'
71
- }
72
- lintOptions {
73
- abortOnError false
74
- }
75
- publishing {
76
- singleVariant("release") {
77
- withSourcesJar()
78
- }
97
+ versionName '12.6.0'
79
98
  }
80
99
  }
81
100
 
82
101
  dependencies {
83
- implementation project(':expo-modules-core')
102
+ // Remove this if and it's contents, when support for SDK49 is dropped
103
+ if (!safeExtGet("expoProvidesDefaultConfig", false)) {
104
+ implementation project(':expo-modules-core')
105
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
106
+ }
84
107
 
85
108
  api "androidx.biometric:biometric:1.1.0"
86
109
 
87
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
88
110
  }
@@ -1,212 +1,89 @@
1
1
  package expo.modules.securestore
2
2
 
3
+ import android.annotation.SuppressLint
3
4
  import android.app.Activity
4
5
  import android.content.Context
5
6
  import android.os.Build
6
- import android.util.Log
7
7
  import androidx.biometric.BiometricManager
8
8
  import androidx.biometric.BiometricPrompt
9
- import androidx.biometric.BiometricPrompt.PromptInfo
10
- import androidx.core.content.ContextCompat
11
9
  import androidx.fragment.app.FragmentActivity
12
10
  import expo.modules.core.ModuleRegistry
13
- import expo.modules.core.Promise
14
- import expo.modules.core.arguments.ReadableArguments
15
11
  import expo.modules.core.interfaces.ActivityProvider
16
- import expo.modules.core.interfaces.services.UIManager
17
- import org.json.JSONException
18
- import org.json.JSONObject
19
- import java.security.GeneralSecurityException
12
+ import kotlinx.coroutines.Dispatchers
13
+ import kotlinx.coroutines.withContext
20
14
  import javax.crypto.Cipher
21
- import javax.crypto.spec.GCMParameterSpec
22
15
 
23
16
  class AuthenticationHelper(
24
17
  private val context: Context,
25
18
  private val moduleRegistry: ModuleRegistry
26
19
  ) {
27
- companion object {
28
- private const val AUTHENTICATION_PROMPT_PROPERTY = "authenticationPrompt"
29
- const val REQUIRE_AUTHENTICATION_PROPERTY = "requireAuthentication"
30
- }
31
-
32
- private val uiManager = moduleRegistry.getModule(UIManager::class.java)
33
20
  private var isAuthenticating = false
34
21
 
35
- // Authentication callback decides whether the operation requires authentication (either by
36
- // requiresAuthentication argument, or from options). When item needs to be encrypted/decrypted an
37
- // instance of Authentication callback is passed to the relevant method.
38
- // The method prepares the cipher and starts authentication callback with it. If the operation
39
- // requires authentication, the biometric prompt is shown, otherwise the encryption callback
40
- // is called.
41
- // When the user is authenticated the encryption callback is ran with the unlocked cipher and does
42
- // encryption/decryption. Finally the PostEncryptionCallback may be ran with the object returned
43
- // by previous callback (to save encrypted data to SharedPreferences).
44
-
45
- val defaultCallback: AuthenticationCallback = object : AuthenticationCallback {
46
- override fun checkAuthentication(
47
- promise: Promise,
48
- cipher: Cipher,
49
- gcmParameterSpec: GCMParameterSpec,
50
- options: ReadableArguments,
51
- encryptionCallback: EncryptionCallback,
52
- postEncryptionCallback: PostEncryptionCallback?
53
- ) {
54
- val requiresAuthentication = options.getBoolean(REQUIRE_AUTHENTICATION_PROPERTY, false)
55
-
56
- checkAuthentication(
57
- promise, requiresAuthentication, cipher, gcmParameterSpec, options, encryptionCallback, postEncryptionCallback
58
- )
59
- }
60
-
61
- override fun checkAuthentication(
62
- promise: Promise,
63
- requiresAuthentication: Boolean,
64
- cipher: Cipher,
65
- gcmParameterSpec: GCMParameterSpec,
66
- options: ReadableArguments,
67
- encryptionCallback: EncryptionCallback,
68
- postEncryptionCallback: PostEncryptionCallback?
69
- ) {
70
- if (requiresAuthentication) {
71
- openAuthenticationPrompt(promise, options, encryptionCallback, cipher, gcmParameterSpec, postEncryptionCallback)
72
- } else {
73
- handleEncryptionCallback(promise, encryptionCallback, cipher, gcmParameterSpec, postEncryptionCallback)
74
- }
75
- }
76
- }
77
-
78
- fun handleEncryptionCallback(
79
- promise: Promise,
80
- encryptionCallback: EncryptionCallback,
81
- cipher: Cipher,
82
- gcmParameterSpec: GCMParameterSpec,
83
- postEncryptionCallback: PostEncryptionCallback?
84
- ) {
85
- try {
86
- encryptionCallback.run(promise, cipher, gcmParameterSpec, postEncryptionCallback)
87
- } catch (exception: GeneralSecurityException) {
88
- Log.w(SecureStoreModule.TAG, exception)
89
- promise.reject(
90
- "ERR_SECURESTORE_ENCRYPT_FAILURE",
91
- "Could not encrypt/decrypt the value for SecureStore",
92
- exception
93
- )
94
- } catch (exception: JSONException) {
95
- Log.w(SecureStoreModule.TAG, exception)
96
- promise.reject(
97
- "ERR_SECURESTORE_ENCODE_FAILURE",
98
- "Could not create an encrypted JSON item for SecureStore",
99
- exception
100
- )
22
+ suspend fun authenticateCipher(cipher: Cipher, requiresAuthentication: Boolean, title: String): Cipher {
23
+ if (requiresAuthentication) {
24
+ return openAuthenticationPrompt(cipher, title).cryptoObject?.cipher
25
+ ?: throw AuthenticationException("Couldn't get cipher from authentication result")
101
26
  }
27
+ return cipher
102
28
  }
103
29
 
104
- private fun openAuthenticationPrompt(
105
- promise: Promise,
106
- options: ReadableArguments,
107
- encryptionCallback: EncryptionCallback,
30
+ private suspend fun openAuthenticationPrompt(
108
31
  cipher: Cipher,
109
- gcmParameterSpec: GCMParameterSpec,
110
- postEncryptionCallback: PostEncryptionCallback?
111
- ) {
32
+ title: String
33
+ ): BiometricPrompt.AuthenticationResult {
112
34
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
113
- promise.reject(
114
- "ERR_SECURESTORE_AUTH_NOT_AVAILABLE",
115
- "Biometric authentication requires Android API 23"
116
- )
117
- return
35
+ throw AuthenticationException("Biometric authentication requires Android API 23")
118
36
  }
119
37
  if (isAuthenticating) {
120
- promise.reject(
121
- "ERR_SECURESTORE_AUTH_IN_PROGRESS",
122
- "Authentication is already in progress"
123
- )
124
- return
38
+ throw AuthenticationException("Authentication is already in progress")
39
+ }
40
+
41
+ isAuthenticating = true
42
+
43
+ assertBiometricsSupport()
44
+ val fragmentActivity = getCurrentActivity() as? FragmentActivity
45
+ ?: throw AuthenticationException("Cannot display biometric prompt when the app is not in the foreground")
46
+
47
+ val authenticationPrompt = AuthenticationPrompt(fragmentActivity, context, title)
48
+
49
+ return withContext(Dispatchers.Main.immediate) {
50
+ try {
51
+ return@withContext authenticationPrompt.authenticate(cipher)
52
+ ?: throw AuthenticationException("Couldn't get the authentication result")
53
+ } finally {
54
+ isAuthenticating = false
55
+ }
125
56
  }
57
+ }
126
58
 
59
+ fun assertBiometricsSupport() {
127
60
  val biometricManager = BiometricManager.from(context)
61
+ @SuppressLint("SwitchIntDef") // BiometricManager.BIOMETRIC_SUCCESS shouldn't do anything
128
62
  when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
129
63
  BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
130
- promise.reject(
131
- "ERR_SECURESTORE_AUTH_NOT_AVAILABLE",
132
- "No hardware available for biometric authentication. Use expo-local-authentication to check if the device supports it."
133
- )
134
- return
64
+ throw AuthenticationException("No hardware available for biometric authentication. Use expo-local-authentication to check if the device supports it")
135
65
  }
136
66
  BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
137
- promise.reject(
138
- "ERR_SECURESTORE_AUTH_NOT_CONFIGURED",
139
- "No biometrics are currently enrolled"
140
- )
141
- return
67
+ throw AuthenticationException("No biometrics are currently enrolled")
142
68
  }
143
- }
144
-
145
- val title = options.getString(AUTHENTICATION_PROMPT_PROPERTY, " ")
146
-
147
- val promptInfo = PromptInfo.Builder()
148
- .setTitle(title)
149
- .setNegativeButtonText(context.getString(android.R.string.cancel))
150
- .build()
151
- val fragmentActivity = getCurrentActivity() as FragmentActivity?
152
- if (fragmentActivity == null) {
153
- promise.reject(
154
- "ERR_SECURESTORE_APP_BACKGROUNDED",
155
- "Cannot display biometric prompt when the app is not in the foreground"
156
- )
157
- return
158
- }
159
-
160
- uiManager.runOnUiQueueThread(
161
- Runnable {
162
- isAuthenticating = true
163
-
164
- BiometricPrompt(
165
- fragmentActivity,
166
- ContextCompat.getMainExecutor(context),
167
- object : BiometricPrompt.AuthenticationCallback() {
168
- override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
169
- super.onAuthenticationSucceeded(result)
170
- isAuthenticating = false
171
-
172
- val cipher = result.cryptoObject!!.cipher!!
173
- handleEncryptionCallback(
174
- promise,
175
- encryptionCallback,
176
- cipher,
177
- gcmParameterSpec,
178
- { promise, result ->
179
- val obj = result as JSONObject
180
- obj.put(REQUIRE_AUTHENTICATION_PROPERTY, true)
181
- postEncryptionCallback?.run(promise, result)
182
- }
183
- )
184
- }
185
-
186
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
187
- super.onAuthenticationError(errorCode, errString)
188
- isAuthenticating = false
189
-
190
- if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
191
- promise.reject(
192
- "ERR_SECURESTORE_AUTH_CANCELLED",
193
- "User canceled the authentication"
194
- )
195
- } else {
196
- promise.reject(
197
- "ERR_SECURESTORE_AUTH_FAILURE",
198
- "Could not authenticate the user"
199
- )
200
- }
201
- }
202
- }
203
- ).authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
69
+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
70
+ throw AuthenticationException("An update is required before the biometrics can be used")
71
+ }
72
+ BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
73
+ throw AuthenticationException("Biometric authentication is unsupported")
204
74
  }
205
- )
75
+ BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
76
+ throw AuthenticationException("Biometric authentication status is unknown")
77
+ }
78
+ }
206
79
  }
207
80
 
208
81
  private fun getCurrentActivity(): Activity? {
209
82
  val activityProvider: ActivityProvider = moduleRegistry.getModule(ActivityProvider::class.java)
210
83
  return activityProvider.currentActivity
211
84
  }
85
+
86
+ companion object {
87
+ const val REQUIRE_AUTHENTICATION_PROPERTY = "requireAuthentication"
88
+ }
212
89
  }
@@ -0,0 +1,44 @@
1
+ package expo.modules.securestore
2
+
3
+ import android.content.Context
4
+ import androidx.biometric.BiometricPrompt
5
+ import androidx.biometric.BiometricPrompt.PromptInfo
6
+ import androidx.core.content.ContextCompat
7
+ import androidx.fragment.app.FragmentActivity
8
+ import java.util.concurrent.Executor
9
+ import javax.crypto.Cipher
10
+ import kotlin.coroutines.resume
11
+ import kotlin.coroutines.resumeWithException
12
+ import kotlin.coroutines.suspendCoroutine
13
+
14
+ class AuthenticationPrompt(private val currentActivity: FragmentActivity, context: Context, title: String) {
15
+ private var executor: Executor = ContextCompat.getMainExecutor(context)
16
+ private var promptInfo = PromptInfo.Builder()
17
+ .setTitle(title)
18
+ .setNegativeButtonText(context.getString(android.R.string.cancel))
19
+ .build()
20
+
21
+ suspend fun authenticate(cipher: Cipher): BiometricPrompt.AuthenticationResult? =
22
+ suspendCoroutine { continuation ->
23
+ BiometricPrompt(
24
+ currentActivity,
25
+ executor,
26
+ object : BiometricPrompt.AuthenticationCallback() {
27
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
28
+ super.onAuthenticationError(errorCode, errString)
29
+
30
+ if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
31
+ continuation.resumeWithException(AuthenticationException("User canceled the authentication"))
32
+ } else {
33
+ continuation.resumeWithException(AuthenticationException("Could not authenticate the user"))
34
+ }
35
+ }
36
+
37
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
38
+ super.onAuthenticationSucceeded(result)
39
+ continuation.resume(result)
40
+ }
41
+ }
42
+ ).authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
43
+ }
44
+ }
@@ -1,27 +1,24 @@
1
1
  package expo.modules.securestore
2
2
 
3
- import expo.modules.core.errors.CodedException
3
+ import expo.modules.kotlin.exception.CodedException
4
4
 
5
5
  internal class NullKeyException :
6
6
  CodedException("SecureStore keys must not be null")
7
7
 
8
- internal class WriteException(message: String?, cause: Throwable?) :
9
- CodedException(message ?: "An unexpected error occurred when writing to SecureStore", cause)
8
+ internal class WriteException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
9
+ CodedException("An error occurred when writing to key: '$key' under keychain: '$keychain'. Caused by: ${message ?: "unknown"}", cause)
10
10
 
11
- internal class ReadException(cause: Throwable?) :
12
- CodedException("An unexpected error occurred when reading from SecureStore", cause)
11
+ internal class EncryptException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
12
+ CodedException("Could not encrypt the value for key '$key' under keychain '$keychain'. Caused by: ${message ?: "unknown"}", cause)
13
13
 
14
- internal class SecureStoreIOException(cause: Throwable?) :
15
- CodedException("There was an I/O error loading the keystore for SecureStore", cause)
14
+ internal class DecryptException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
15
+ CodedException("Could not decrypt the value for key '$key' under keychain '$keychain'. Caused by: ${message ?: "unknown"}", cause)
16
16
 
17
- internal class EncryptException(message: String?, cause: Throwable?) :
18
- CodedException(message ?: "Could not encrypt the value for SecureStore", cause)
17
+ internal class DeleteException(message: String?, key: String, keychain: String, cause: Throwable? = null) :
18
+ CodedException("Could not delete the value for key '$key' under keychain '$keychain'. Caused by: ${message ?: "unknown"}", cause)
19
19
 
20
- internal class DecryptException(message: String?, cause: Throwable?) :
21
- CodedException(message ?: "Could not decrypt the value for SecureStore", cause)
20
+ internal class AuthenticationException(message: String?, cause: Throwable? = null) :
21
+ CodedException("Could not Authenticate the user: ${message ?: "unknown"}", cause)
22
22
 
23
- internal class SecureStoreJSONException(message: String?, cause: Throwable?) :
24
- CodedException(message, cause)
25
-
26
- internal class DeleteException(message: String?, cause: Throwable?) :
27
- CodedException(message ?: "An unexpected error occurred when deleting from SecureStore", cause)
23
+ internal class KeyStoreException(message: String?) :
24
+ CodedException("An error occurred when accessing the keystore: ${message ?: "unknown"}")