expo-secure-store 12.6.0 β†’ 12.8.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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,24 @@
10
10
 
11
11
  ### πŸ’‘ Others
12
12
 
13
+ ## 12.8.0 β€” 2023-12-12
14
+
15
+ ### πŸŽ‰ New features
16
+
17
+ - [iOS] Added possibility to store values that require authentication and ones that don't under the same `keychainService`. ([#23841](https://github.com/expo/expo/pull/23841) by [@behenate](https://github.com/behenate))
18
+ - [iOS] Added synchronous functions for storing and retrieving values from the store. ([#23841](https://github.com/expo/expo/pull/23841) by [@behenate](https://github.com/behenate))
19
+
20
+ ## 12.7.0 β€” 2023-11-14
21
+
22
+ ### πŸ›  Breaking changes
23
+
24
+ - Bumped iOS deployment target to 13.4. ([#25063](https://github.com/expo/expo/pull/25063) by [@gabrieldonadel](https://github.com/gabrieldonadel))
25
+ - On `Android` bump `compileSdkVersion` and `targetSdkVersion` to `34`. ([#24708](https://github.com/expo/expo/pull/24708) by [@alanjhughes](https://github.com/alanjhughes))
26
+
27
+ ### πŸ’‘ Others
28
+
29
+ - [Android] Enforce minimum authentication tag length for the `AESEncryptor` for improved security. ([#25294](https://github.com/expo/expo/pull/25294) by [@behenate](https://github.com/behenate))
30
+
13
31
  ## 12.6.0 β€” 2023-10-17
14
32
 
15
33
  ### πŸ›  Breaking changes
@@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
3
3
  apply plugin: 'maven-publish'
4
4
 
5
5
  group = 'host.exp.exponent'
6
- version = '12.6.0'
6
+ version = '12.8.0'
7
7
 
8
8
  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
9
9
  if (expoModulesCorePlugin.exists()) {
@@ -61,11 +61,11 @@ if (!safeExtGet("expoProvidesDefaultConfig", false)) {
61
61
  android {
62
62
  // Remove this if and it's contents, when support for SDK49 is dropped
63
63
  if (!safeExtGet("expoProvidesDefaultConfig", false)) {
64
- compileSdkVersion safeExtGet("compileSdkVersion", 33)
64
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
65
65
 
66
66
  defaultConfig {
67
67
  minSdkVersion safeExtGet("minSdkVersion", 23)
68
- targetSdkVersion safeExtGet("targetSdkVersion", 33)
68
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
69
69
  }
70
70
 
71
71
  publishing {
@@ -94,7 +94,7 @@ android {
94
94
  namespace "expo.modules.securestore"
95
95
  defaultConfig {
96
96
  versionCode 17
97
- versionName '12.6.0'
97
+ versionName '12.8.0'
98
98
  }
99
99
  }
100
100
 
@@ -126,12 +126,12 @@ open class SecureStoreModule : Module() {
126
126
  AESEncryptor.NAME -> {
127
127
  val secretKeyEntry = getPreferredKeyEntry(SecretKeyEntry::class.java, mAESEncryptor, options, requireAuthentication, usesKeystoreSuffix)
128
128
  ?: throw DecryptException("Could not find a keychain for key $key$legacyReadFailedWarning", key, options.keychainService)
129
- return mAESEncryptor.decryptItem(encryptedItem, secretKeyEntry, options, authenticationHelper)
129
+ return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper)
130
130
  }
131
131
  HybridAESEncryptor.NAME -> {
132
132
  val privateKeyEntry = getPreferredKeyEntry(PrivateKeyEntry::class.java, hybridAESEncryptor, options, requireAuthentication, usesKeystoreSuffix)
133
133
  ?: throw DecryptException("Could not find a keychain for key $key$legacyReadFailedWarning", key, options.keychainService)
134
- return hybridAESEncryptor.decryptItem(encryptedItem, privateKeyEntry, options, authenticationHelper)
134
+ return hybridAESEncryptor.decryptItem(key, encryptedItem, privateKeyEntry, options, authenticationHelper)
135
135
  }
136
136
  else -> {
137
137
  throw DecryptException("The item for key $key in SecureStore has an unknown encoding scheme $scheme)", key, options.keychainService)
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
5
5
  import android.security.keystore.KeyProperties
6
6
  import android.util.Base64
7
7
  import expo.modules.securestore.AuthenticationHelper
8
+ import expo.modules.securestore.DecryptException
8
9
  import expo.modules.securestore.SecureStoreModule
9
10
  import expo.modules.securestore.SecureStoreOptions
10
11
  import org.json.JSONException
@@ -108,6 +109,7 @@ class AESEncryptor : KeyBasedEncryptor<KeyStore.SecretKeyEntry> {
108
109
 
109
110
  @Throws(GeneralSecurityException::class, JSONException::class)
110
111
  override suspend fun decryptItem(
112
+ key: String,
111
113
  encryptedItem: JSONObject,
112
114
  keyStoreEntry: KeyStore.SecretKeyEntry,
113
115
  options: SecureStoreOptions,
@@ -122,6 +124,9 @@ class AESEncryptor : KeyBasedEncryptor<KeyStore.SecretKeyEntry> {
122
124
  val cipher = Cipher.getInstance(AES_CIPHER)
123
125
  val requiresAuthentication = encryptedItem.optBoolean(AuthenticationHelper.REQUIRE_AUTHENTICATION_PROPERTY)
124
126
 
127
+ if (authenticationTagLength < MIN_GCM_AUTHENTICATION_TAG_LENGTH) {
128
+ throw DecryptException("Authentication tag length must be at least $MIN_GCM_AUTHENTICATION_TAG_LENGTH bits long", key, options.keychainService)
129
+ }
125
130
  cipher.init(Cipher.DECRYPT_MODE, keyStoreEntry.secretKey, gcmSpec)
126
131
  val unlockedCipher = authenticationHelper.authenticateCipher(cipher, requiresAuthentication, options.authenticationPrompt)
127
132
  return String(unlockedCipher.doFinal(ciphertextBytes), StandardCharsets.UTF_8)
@@ -134,5 +139,6 @@ class AESEncryptor : KeyBasedEncryptor<KeyStore.SecretKeyEntry> {
134
139
  private const val CIPHERTEXT_PROPERTY = "ct"
135
140
  const val IV_PROPERTY = "iv"
136
141
  private const val GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY = "tlen"
142
+ private const val MIN_GCM_AUTHENTICATION_TAG_LENGTH = 96
137
143
  }
138
144
  }
@@ -80,7 +80,13 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto
80
80
  }
81
81
 
82
82
  @Throws(GeneralSecurityException::class, JSONException::class)
83
- override suspend fun decryptItem(encryptedItem: JSONObject, keyStoreEntry: KeyStore.PrivateKeyEntry, options: SecureStoreOptions, authenticationHelper: AuthenticationHelper): String {
83
+ override suspend fun decryptItem(
84
+ key: String,
85
+ encryptedItem: JSONObject,
86
+ keyStoreEntry: KeyStore.PrivateKeyEntry,
87
+ options: SecureStoreOptions,
88
+ authenticationHelper: AuthenticationHelper
89
+ ): String {
84
90
  // Decrypt the encrypted symmetric key
85
91
  val encryptedSecretKeyString = encryptedItem.getString(ENCRYPTED_SECRET_KEY_PROPERTY)
86
92
  val encryptedSecretKeyBytes = Base64.decode(encryptedSecretKeyString, Base64.DEFAULT)
@@ -92,7 +98,7 @@ class HybridAESEncryptor(private var mContext: Context, private val mAESEncrypto
92
98
 
93
99
  // Decrypt the value with the symmetric key
94
100
  val secretKeyEntry = KeyStore.SecretKeyEntry(secretKey)
95
- return mAESEncryptor.decryptItem(encryptedItem, secretKeyEntry, options, authenticationHelper)
101
+ return mAESEncryptor.decryptItem(key, encryptedItem, secretKeyEntry, options, authenticationHelper)
96
102
  }
97
103
 
98
104
  @get:Throws(NoSuchAlgorithmException::class, NoSuchProviderException::class, NoSuchPaddingException::class)
@@ -30,6 +30,7 @@ interface KeyBasedEncryptor<E : KeyStore.Entry> {
30
30
 
31
31
  @Throws(GeneralSecurityException::class, JSONException::class)
32
32
  suspend fun decryptItem(
33
+ key: String,
33
34
  encryptedItem: JSONObject,
34
35
  keyStoreEntry: E,
35
36
  options: SecureStoreOptions,
@@ -10,7 +10,7 @@ Pod::Spec.new do |s|
10
10
  s.license = package['license']
11
11
  s.author = package['author']
12
12
  s.homepage = package['homepage']
13
- s.platform = :ios, '13.0'
13
+ s.platform = :ios, '13.4'
14
14
  s.swift_version = '5.4'
15
15
  s.source = { git: 'https://github.com/expo/expo.git' }
16
16
  s.static_framework = true
@@ -22,7 +22,7 @@ Pod::Spec.new do |s|
22
22
  'DEFINES_MODULE' => 'YES',
23
23
  'SWIFT_COMPILATION_MODE' => 'wholemodule'
24
24
  }
25
-
25
+
26
26
  if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
27
27
  s.source_files = "**/*.h"
28
28
  s.vendored_frameworks = "#{s.name}.xcframework"
@@ -17,20 +17,22 @@ public final class SecureStoreModule: Module {
17
17
  ])
18
18
 
19
19
  AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in
20
- guard let key = validate(for: key) else {
21
- throw InvalidKeyException()
22
- }
20
+ return try get(with: key, options: options)
21
+ }
23
22
 
24
- let data = try searchKeyChain(with: key, options: options)
23
+ Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in
24
+ return try get(with: key, options: options)
25
+ }
25
26
 
26
- guard let data = data else {
27
- return nil
27
+ AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in
28
+ guard let key = validate(for: key) else {
29
+ throw InvalidKeyException()
28
30
  }
29
31
 
30
- return String(data: data, encoding: .utf8)
32
+ return try set(value: value, with: key, options: options)
31
33
  }
32
34
 
33
- AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in
35
+ Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in
34
36
  guard let key = validate(for: key) else {
35
37
  throw InvalidKeyException()
36
38
  }
@@ -39,33 +41,61 @@ public final class SecureStoreModule: Module {
39
41
  }
40
42
 
41
43
  AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in
42
- let searchDictionary = query(with: key, options: options)
43
- SecItemDelete(searchDictionary as CFDictionary)
44
+ let noAuthSearchDictionary = query(with: key, options: options, requireAuthentication: false)
45
+ let authSearchDictionary = query(with: key, options: options, requireAuthentication: true)
46
+ let legacySearchDictionary = query(with: key, options: options)
47
+
48
+ SecItemDelete(legacySearchDictionary as CFDictionary)
49
+ SecItemDelete(authSearchDictionary as CFDictionary)
50
+ SecItemDelete(noAuthSearchDictionary as CFDictionary)
44
51
  }
45
52
  }
46
53
 
54
+ private func get(with key: String, options: SecureStoreOptions) throws -> String? {
55
+ guard let key = validate(for: key) else {
56
+ throw InvalidKeyException()
57
+ }
58
+
59
+ if let unauthenticatedItem = try searchKeyChain(with: key, options: options, requireAuthentication: false) {
60
+ return String(data: unauthenticatedItem, encoding: .utf8)
61
+ }
62
+
63
+ if let authenticatedItem = try searchKeyChain(with: key, options: options, requireAuthentication: true) {
64
+ return String(data: authenticatedItem, encoding: .utf8)
65
+ }
66
+
67
+ if let legacyItem = try searchKeyChain(with: key, options: options) {
68
+ return String(data: legacyItem, encoding: .utf8)
69
+ }
70
+
71
+ return nil
72
+ }
73
+
47
74
  private func set(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
48
- var query = query(with: key, options: options)
75
+ var setItemQuery = query(with: key, options: options, requireAuthentication: options.requireAuthentication)
49
76
 
50
77
  let valueData = value.data(using: .utf8)
51
- query[kSecValueData as String] = valueData
78
+ setItemQuery[kSecValueData as String] = valueData
52
79
 
53
80
  let accessibility = attributeWith(options: options)
54
81
 
55
82
  if !options.requireAuthentication {
56
- query[kSecAttrAccessible as String] = accessibility
83
+ setItemQuery[kSecAttrAccessible as String] = accessibility
57
84
  } else {
58
85
  guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else {
59
86
  throw MissingPlistKeyException()
60
87
  }
61
88
  let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, SecAccessControlCreateFlags.biometryCurrentSet, nil)
62
- query[kSecAttrAccessControl as String] = accessOptions
89
+ setItemQuery[kSecAttrAccessControl as String] = accessOptions
63
90
  }
64
91
 
65
- let status = SecItemAdd(query as CFDictionary, nil)
92
+ let status = SecItemAdd(setItemQuery as CFDictionary, nil)
66
93
 
67
94
  switch status {
68
95
  case errSecSuccess:
96
+ // On success we want to remove the other key alias and legacy key (if they exist) to avoid conflicts during reads
97
+ SecItemDelete(query(with: key, options: options) as CFDictionary)
98
+ SecItemDelete(query(with: key, options: options, requireAuthentication: !options.requireAuthentication) as CFDictionary)
69
99
  return true
70
100
  case errSecDuplicateItem:
71
101
  return try update(value: value, with: key, options: options)
@@ -75,7 +105,7 @@ public final class SecureStoreModule: Module {
75
105
  }
76
106
 
77
107
  private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
78
- var query = query(with: key, options: options)
108
+ var query = query(with: key, options: options, requireAuthentication: options.requireAuthentication)
79
109
 
80
110
  let valueData = value.data(using: .utf8)
81
111
  let updateDictionary = [kSecValueData as String: valueData]
@@ -93,8 +123,8 @@ public final class SecureStoreModule: Module {
93
123
  }
94
124
  }
95
125
 
96
- private func searchKeyChain(with key: String, options: SecureStoreOptions) throws -> Data? {
97
- var query = query(with: key, options: options)
126
+ private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? {
127
+ var query = query(with: key, options: options, requireAuthentication: requireAuthentication)
98
128
 
99
129
  query[kSecMatchLimit as String] = kSecMatchLimitOne
100
130
  query[kSecReturnData as String] = kCFBooleanTrue
@@ -119,8 +149,12 @@ public final class SecureStoreModule: Module {
119
149
  }
120
150
  }
121
151
 
122
- private func query(with key: String, options: SecureStoreOptions) -> [String: Any] {
123
- let service = options.keychainService ?? "app"
152
+ private func query(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) -> [String: Any] {
153
+ var service = options.keychainService ?? "app"
154
+ if let requireAuthentication {
155
+ service.append(":\(requireAuthentication ? "auth" : "no-auth")")
156
+ }
157
+
124
158
  let encodedKey = Data(key.utf8)
125
159
 
126
160
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-secure-store",
3
- "version": "12.6.0",
3
+ "version": "12.8.0",
4
4
  "description": "Provides a way to encrypt and securely store key–value pairs locally on the device.",
5
5
  "main": "build/SecureStore.js",
6
6
  "types": "build/SecureStore.d.ts",
@@ -42,5 +42,5 @@
42
42
  "peerDependencies": {
43
43
  "expo": "*"
44
44
  },
45
- "gitHead": "da25937e2a99661cbe5eb60ca1d8d6245fc96a50"
45
+ "gitHead": "6aca7ce098ddc667776a3d7cf612adbb985e264a"
46
46
  }