react-native-nitro-storage 0.5.9 → 0.7.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 +39 -0
- package/README.md +193 -16
- package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +225 -76
- package/app.plugin.js +2 -0
- package/cpp/bindings/HybridStorage.cpp +29 -21
- package/cpp/bindings/HybridStorage.hpp +5 -0
- package/docs/secure-storage.md +4 -0
- package/lib/commonjs/index.js +19 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +19 -2
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/indexeddb-backend.js.map +1 -1
- package/lib/commonjs/internal.js.map +1 -1
- package/lib/commonjs/shared.js.map +1 -1
- package/lib/commonjs/storage-core.js +306 -10
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/commonjs/storage-events.js.map +1 -1
- package/lib/commonjs/storage-hooks.js +16 -1
- package/lib/commonjs/storage-hooks.js.map +1 -1
- package/lib/commonjs/testing.js +267 -0
- package/lib/commonjs/testing.js.map +1 -0
- package/lib/module/index.js +5 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +5 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/indexeddb-backend.js.map +1 -1
- package/lib/module/internal.js.map +1 -1
- package/lib/module/shared.js.map +1 -1
- package/lib/module/storage-core.js +307 -11
- package/lib/module/storage-core.js.map +1 -1
- package/lib/module/storage-events.js.map +1 -1
- package/lib/module/storage-hooks.js +15 -2
- package/lib/module/storage-hooks.js.map +1 -1
- package/lib/module/testing.js +188 -0
- package/lib/module/testing.js.map +1 -0
- package/lib/typescript/index.d.ts +28 -5
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +28 -5
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
- package/lib/typescript/shared.d.ts +1 -0
- package/lib/typescript/shared.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts +62 -3
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/storage-events.d.ts +1 -1
- package/lib/typescript/storage-events.d.ts.map +1 -1
- package/lib/typescript/storage-hooks.d.ts +18 -1
- package/lib/typescript/storage-hooks.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts +158 -0
- package/lib/typescript/testing.d.ts.map +1 -0
- package/package.json +11 -6
- package/src/index.ts +17 -4
- package/src/index.web.ts +17 -4
- package/src/indexeddb-backend.ts +1 -2
- package/src/internal.ts +1 -1
- package/src/shared.ts +1 -0
- package/src/storage-core.ts +463 -11
- package/src/storage-events.ts +3 -2
- package/src/storage-hooks.ts +42 -3
- package/src/testing.ts +270 -0
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
package com.nitrostorage
|
|
4
4
|
|
|
5
5
|
import android.content.Context
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
6
8
|
import android.security.keystore.KeyPermanentlyInvalidatedException
|
|
9
|
+
import android.security.keystore.KeyProperties
|
|
7
10
|
import android.security.keystore.UserNotAuthenticatedException
|
|
8
11
|
import android.content.SharedPreferences
|
|
9
12
|
import androidx.security.crypto.EncryptedSharedPreferences
|
|
@@ -60,33 +63,113 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
60
63
|
throw RuntimeException("NitroStorage: Cannot create encryption key. Device may not support AES256-GCM.", e)
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
private val encryptedPreferences: SharedPreferences = initializeEncryptedPreferences(
|
|
66
|
+
private val encryptedPreferences: SharedPreferences = initializeEncryptedPreferences(
|
|
67
|
+
"NitroStorageSecure",
|
|
68
|
+
masterKey,
|
|
69
|
+
masterKeyAlias,
|
|
70
|
+
::createDefaultMasterKey,
|
|
71
|
+
)
|
|
64
72
|
|
|
65
73
|
private val biometricMasterKeyAlias = "${context.packageName}.nitro_storage.biometric_key"
|
|
74
|
+
private val biometricOrPasscodeMasterKeyAlias =
|
|
75
|
+
"${context.packageName}.nitro_storage.biometric_or_passcode_key"
|
|
76
|
+
private val biometricOnlyMasterKeyAlias =
|
|
77
|
+
"${context.packageName}.nitro_storage.biometric_only_key"
|
|
78
|
+
|
|
79
|
+
private val legacyBiometricPreferences: SharedPreferences by lazy {
|
|
80
|
+
createBiometricPreferences(
|
|
81
|
+
"NitroStorageBiometric",
|
|
82
|
+
biometricMasterKeyAlias,
|
|
83
|
+
1,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
66
86
|
|
|
67
|
-
private val
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
private val biometricOrPasscodePreferences: SharedPreferences by lazy {
|
|
88
|
+
createBiometricPreferences(
|
|
89
|
+
"NitroStorageBiometricOrPasscode",
|
|
90
|
+
biometricOrPasscodeMasterKeyAlias,
|
|
91
|
+
1,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private val biometricOnlyPreferences: SharedPreferences by lazy {
|
|
96
|
+
createBiometricPreferences(
|
|
97
|
+
"NitroStorageBiometricOnly",
|
|
98
|
+
biometricOnlyMasterKeyAlias,
|
|
99
|
+
2,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Volatile
|
|
104
|
+
private var secureWritesAsync = false
|
|
105
|
+
|
|
106
|
+
@Volatile
|
|
107
|
+
private var secureKeysCache: Array<String>? = null
|
|
108
|
+
|
|
109
|
+
private fun createDefaultMasterKey(): MasterKey {
|
|
110
|
+
return MasterKey.Builder(context, masterKeyAlias)
|
|
111
|
+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
112
|
+
.build()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fun createBiometricMasterKey(alias: String, level: Int): MasterKey {
|
|
116
|
+
if (level == 2 && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
117
|
+
throw RuntimeException(
|
|
118
|
+
"[nitro-error:biometric_unavailable] NitroStorage: BiometryOnly requires Android 11 or newer.",
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
123
|
+
val authenticationTypes = if (level == 1) {
|
|
124
|
+
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL
|
|
125
|
+
} else {
|
|
126
|
+
KeyProperties.AUTH_BIOMETRIC_STRONG
|
|
127
|
+
}
|
|
128
|
+
val keySpec = KeyGenParameterSpec.Builder(
|
|
129
|
+
alias,
|
|
130
|
+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
131
|
+
)
|
|
132
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
133
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
134
|
+
.setKeySize(256)
|
|
135
|
+
.setUserAuthenticationRequired(true)
|
|
136
|
+
.setUserAuthenticationParameters(30, authenticationTypes)
|
|
137
|
+
.setInvalidatedByBiometricEnrollment(level == 2)
|
|
138
|
+
.build()
|
|
139
|
+
return MasterKey.Builder(context, alias)
|
|
140
|
+
.setKeyGenParameterSpec(keySpec)
|
|
72
141
|
.build()
|
|
73
|
-
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return MasterKey.Builder(context, alias)
|
|
145
|
+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
146
|
+
.setUserAuthenticationRequired(true, 30)
|
|
147
|
+
.build()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private fun createBiometricPreferences(
|
|
151
|
+
name: String,
|
|
152
|
+
alias: String,
|
|
153
|
+
level: Int,
|
|
154
|
+
): SharedPreferences {
|
|
155
|
+
return try {
|
|
156
|
+
val keyFactory = { createBiometricMasterKey(alias, level) }
|
|
157
|
+
initializeEncryptedPreferences(name, keyFactory(), alias, keyFactory)
|
|
74
158
|
} catch (e: Exception) {
|
|
75
159
|
throw e.wrapStorageException(
|
|
76
160
|
"NitroStorage: Biometric storage is not available on this device. " +
|
|
77
|
-
"Ensure
|
|
161
|
+
"Ensure supported authentication is enrolled.",
|
|
78
162
|
defaultCode = "biometric_unavailable",
|
|
79
163
|
)
|
|
80
164
|
}
|
|
81
165
|
}
|
|
82
166
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
private fun initializeEncryptedPreferences(name: String, key: MasterKey): SharedPreferences {
|
|
167
|
+
private fun initializeEncryptedPreferences(
|
|
168
|
+
name: String,
|
|
169
|
+
key: MasterKey,
|
|
170
|
+
alias: String,
|
|
171
|
+
keyFactory: () -> MasterKey,
|
|
172
|
+
): SharedPreferences {
|
|
90
173
|
return try {
|
|
91
174
|
EncryptedSharedPreferences.create(
|
|
92
175
|
context, name, key,
|
|
@@ -96,16 +179,8 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
96
179
|
} catch (e: Exception) {
|
|
97
180
|
when {
|
|
98
181
|
e.hasCause(AEADBadTagException::class.java) -> {
|
|
99
|
-
clearCorruptedStorage(name,
|
|
100
|
-
val
|
|
101
|
-
val freshKey = MasterKey.Builder(context, freshAlias)
|
|
102
|
-
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
103
|
-
.apply {
|
|
104
|
-
if (name == "NitroStorageBiometric") {
|
|
105
|
-
setUserAuthenticationRequired(true, 30)
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
.build()
|
|
182
|
+
clearCorruptedStorage(name, alias)
|
|
183
|
+
val freshKey = keyFactory()
|
|
109
184
|
try {
|
|
110
185
|
EncryptedSharedPreferences.create(
|
|
111
186
|
context, name, freshKey,
|
|
@@ -120,7 +195,6 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
120
195
|
}
|
|
121
196
|
}
|
|
122
197
|
else -> {
|
|
123
|
-
// Don't wipe on non-corruption failures (e.g., locked keystore)
|
|
124
198
|
throw e.wrapStorageException(
|
|
125
199
|
"NitroStorage: Failed to initialize $name (${e::class.simpleName}). " +
|
|
126
200
|
"This may be a temporary keystore issue. If it persists, clear app data.",
|
|
@@ -130,18 +204,58 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
130
204
|
}
|
|
131
205
|
}
|
|
132
206
|
|
|
133
|
-
private fun clearCorruptedStorage(name: String,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
207
|
+
private fun clearCorruptedStorage(name: String, alias: String) {
|
|
208
|
+
context.deleteSharedPreferences(name)
|
|
209
|
+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
|
210
|
+
keyStore.load(null)
|
|
211
|
+
keyStore.deleteEntry(alias)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private fun preferencesFileExists(name: String): Boolean {
|
|
215
|
+
return context.getSharedPreferences(name, Context.MODE_PRIVATE).all.isNotEmpty()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private fun existingBiometricPreferences(): List<SharedPreferences> {
|
|
219
|
+
val stores = mutableListOf<SharedPreferences>()
|
|
220
|
+
if (preferencesFileExists("NitroStorageBiometricOnly")) {
|
|
221
|
+
stores.add(biometricOnlyPreferences)
|
|
222
|
+
}
|
|
223
|
+
if (preferencesFileExists("NitroStorageBiometricOrPasscode")) {
|
|
224
|
+
stores.add(biometricOrPasscodePreferences)
|
|
225
|
+
}
|
|
226
|
+
if (preferencesFileExists("NitroStorageBiometric")) {
|
|
227
|
+
stores.add(legacyBiometricPreferences)
|
|
228
|
+
}
|
|
229
|
+
for (preferences in stores) {
|
|
230
|
+
preferences.all
|
|
231
|
+
}
|
|
232
|
+
return stores
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private fun biometricPreferencesForLevel(level: Int): SharedPreferences {
|
|
236
|
+
return when (level) {
|
|
237
|
+
1 -> biometricOrPasscodePreferences
|
|
238
|
+
2 -> biometricOnlyPreferences
|
|
239
|
+
else -> throw IllegalArgumentException(
|
|
240
|
+
"NitroStorage: Invalid biometric level. Expected 0, 1, or 2.",
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private fun removeBiometricKey(
|
|
246
|
+
key: String,
|
|
247
|
+
preferencesList: List<SharedPreferences> = existingBiometricPreferences(),
|
|
248
|
+
) {
|
|
249
|
+
for (preferences in preferencesList) {
|
|
250
|
+
applySecureEditor(preferences.edit().remove(key))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fun clearBiometricStores(
|
|
255
|
+
preferencesList: List<SharedPreferences> = existingBiometricPreferences(),
|
|
256
|
+
) {
|
|
257
|
+
for (preferences in preferencesList) {
|
|
258
|
+
applySecureEditor(preferences.edit().clear())
|
|
145
259
|
}
|
|
146
260
|
}
|
|
147
261
|
|
|
@@ -162,8 +276,8 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
162
276
|
try {
|
|
163
277
|
if (secureWritesAsync) {
|
|
164
278
|
editor.apply()
|
|
165
|
-
} else {
|
|
166
|
-
|
|
279
|
+
} else if (!editor.commit()) {
|
|
280
|
+
throw IllegalStateException("SharedPreferences commit returned false")
|
|
167
281
|
}
|
|
168
282
|
} catch (e: Exception) {
|
|
169
283
|
throw e.wrapStorageException(
|
|
@@ -192,9 +306,8 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
192
306
|
val INTERNAL_PREFIX = "__androidx_security_crypto_encrypted_prefs_"
|
|
193
307
|
val keys = linkedSetOf<String>()
|
|
194
308
|
keys.addAll(encryptedPreferences.all.keys.filter { !it.startsWith(INTERNAL_PREFIX) })
|
|
195
|
-
|
|
196
|
-
keys.addAll(
|
|
197
|
-
} catch (_: Exception) {
|
|
309
|
+
for (preferences in existingBiometricPreferences()) {
|
|
310
|
+
keys.addAll(preferences.all.keys.filter { !it.startsWith(INTERNAL_PREFIX) })
|
|
198
311
|
}
|
|
199
312
|
val built = keys.toTypedArray()
|
|
200
313
|
secureKeysCache = built
|
|
@@ -361,11 +474,9 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
361
474
|
fun deleteSecure(key: String) {
|
|
362
475
|
val inst = getInstanceOrThrow()
|
|
363
476
|
synchronized(inst) {
|
|
477
|
+
val biometricPreferences = inst.existingBiometricPreferences()
|
|
364
478
|
inst.applySecureEditor(inst.encryptedPreferences.edit().remove(key))
|
|
365
|
-
|
|
366
|
-
inst.applySecureEditor(inst.biometricPreferences.edit().remove(key))
|
|
367
|
-
} catch (_: Exception) {
|
|
368
|
-
}
|
|
479
|
+
inst.removeBiometricKey(key, biometricPreferences)
|
|
369
480
|
inst.invalidateSecureKeysCache()
|
|
370
481
|
}
|
|
371
482
|
}
|
|
@@ -374,18 +485,18 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
374
485
|
fun deleteSecureBatch(keys: Array<String>) {
|
|
375
486
|
val inst = getInstanceOrThrow()
|
|
376
487
|
synchronized(inst) {
|
|
488
|
+
val biometricPreferences = inst.existingBiometricPreferences()
|
|
377
489
|
val editor = inst.encryptedPreferences.edit()
|
|
378
490
|
for (key in keys) {
|
|
379
491
|
editor.remove(key)
|
|
380
492
|
}
|
|
381
493
|
inst.applySecureEditor(editor)
|
|
382
|
-
|
|
383
|
-
val biometricEditor =
|
|
494
|
+
for (preferences in biometricPreferences) {
|
|
495
|
+
val biometricEditor = preferences.edit()
|
|
384
496
|
for (key in keys) {
|
|
385
497
|
biometricEditor.remove(key)
|
|
386
498
|
}
|
|
387
499
|
inst.applySecureEditor(biometricEditor)
|
|
388
|
-
} catch (_: Exception) {
|
|
389
500
|
}
|
|
390
501
|
inst.invalidateSecureKeysCache()
|
|
391
502
|
}
|
|
@@ -394,13 +505,10 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
394
505
|
@JvmStatic
|
|
395
506
|
fun hasSecure(key: String): Boolean {
|
|
396
507
|
val inst = getInstanceOrThrow()
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
inst.biometricPreferences.contains(key)
|
|
400
|
-
} catch (_: Exception) {
|
|
401
|
-
false
|
|
508
|
+
if (inst.encryptedPreferences.contains(key)) {
|
|
509
|
+
return true
|
|
402
510
|
}
|
|
403
|
-
return
|
|
511
|
+
return inst.existingBiometricPreferences().any { it.contains(key) }
|
|
404
512
|
}
|
|
405
513
|
|
|
406
514
|
@JvmStatic
|
|
@@ -422,12 +530,12 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
422
530
|
@JvmStatic
|
|
423
531
|
fun clearSecure() {
|
|
424
532
|
val inst = getInstanceOrThrow()
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
inst.applySecureEditor(inst.
|
|
428
|
-
|
|
533
|
+
synchronized(inst) {
|
|
534
|
+
val biometricPreferences = inst.existingBiometricPreferences()
|
|
535
|
+
inst.applySecureEditor(inst.encryptedPreferences.edit().clear())
|
|
536
|
+
inst.clearBiometricStores(biometricPreferences)
|
|
537
|
+
inst.invalidateSecureKeysCache()
|
|
429
538
|
}
|
|
430
|
-
inst.invalidateSecureKeysCache()
|
|
431
539
|
}
|
|
432
540
|
|
|
433
541
|
// --- Biometric (separate encrypted store, requires recent biometric auth on Android) ---
|
|
@@ -438,12 +546,32 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
438
546
|
}
|
|
439
547
|
|
|
440
548
|
@JvmStatic
|
|
441
|
-
fun setSecureBiometricWithLevel(key: String, value: String,
|
|
549
|
+
fun setSecureBiometricWithLevel(key: String, value: String, level: Int) {
|
|
442
550
|
val inst = getInstanceOrThrow()
|
|
443
551
|
try {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
552
|
+
synchronized(inst) {
|
|
553
|
+
val biometricPreferences = inst.existingBiometricPreferences()
|
|
554
|
+
if (level == 0) {
|
|
555
|
+
inst.removeBiometricKey(key, biometricPreferences)
|
|
556
|
+
inst.applySecureEditor(
|
|
557
|
+
inst.encryptedPreferences.edit().putString(key, value),
|
|
558
|
+
)
|
|
559
|
+
} else {
|
|
560
|
+
val targetPreferences = inst.biometricPreferencesForLevel(level)
|
|
561
|
+
inst.applySecureEditor(
|
|
562
|
+
targetPreferences.edit().putString(key, value),
|
|
563
|
+
)
|
|
564
|
+
inst.applySecureEditor(
|
|
565
|
+
inst.encryptedPreferences.edit().remove(key),
|
|
566
|
+
)
|
|
567
|
+
for (preferences in biometricPreferences) {
|
|
568
|
+
if (preferences !== targetPreferences) {
|
|
569
|
+
inst.applySecureEditor(preferences.edit().remove(key))
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
inst.invalidateSecureKeysCache()
|
|
574
|
+
}
|
|
447
575
|
} catch (e: Exception) {
|
|
448
576
|
throw e.wrapStorageException(
|
|
449
577
|
"NitroStorage: Biometric storage unavailable on this device",
|
|
@@ -456,9 +584,17 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
456
584
|
fun getSecureBiometric(key: String): String? {
|
|
457
585
|
val inst = getInstanceOrThrow()
|
|
458
586
|
return try {
|
|
459
|
-
inst.
|
|
460
|
-
|
|
587
|
+
for (preferences in inst.existingBiometricPreferences()) {
|
|
588
|
+
val value = inst.getSecureSafe(preferences, key)
|
|
589
|
+
if (value != null) {
|
|
590
|
+
return value
|
|
591
|
+
}
|
|
592
|
+
}
|
|
461
593
|
null
|
|
594
|
+
} catch (e: Exception) {
|
|
595
|
+
throw e.wrapStorageException(
|
|
596
|
+
"NitroStorage: Failed to read biometric storage: ${e.message}",
|
|
597
|
+
)
|
|
462
598
|
}
|
|
463
599
|
}
|
|
464
600
|
|
|
@@ -466,18 +602,26 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
466
602
|
fun deleteSecureBiometric(key: String) {
|
|
467
603
|
val inst = getInstanceOrThrow()
|
|
468
604
|
try {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
605
|
+
synchronized(inst) {
|
|
606
|
+
inst.removeBiometricKey(key)
|
|
607
|
+
inst.invalidateSecureKeysCache()
|
|
608
|
+
}
|
|
609
|
+
} catch (e: Exception) {
|
|
610
|
+
throw e.wrapStorageException(
|
|
611
|
+
"NitroStorage: Failed to delete biometric storage: ${e.message}",
|
|
612
|
+
)
|
|
472
613
|
}
|
|
473
614
|
}
|
|
474
615
|
|
|
475
616
|
@JvmStatic
|
|
476
617
|
fun hasSecureBiometric(key: String): Boolean {
|
|
618
|
+
val inst = getInstanceOrThrow()
|
|
477
619
|
return try {
|
|
478
|
-
|
|
479
|
-
} catch (
|
|
480
|
-
|
|
620
|
+
inst.existingBiometricPreferences().any { it.contains(key) }
|
|
621
|
+
} catch (e: Exception) {
|
|
622
|
+
throw e.wrapStorageException(
|
|
623
|
+
"NitroStorage: Failed to inspect biometric storage: ${e.message}",
|
|
624
|
+
)
|
|
481
625
|
}
|
|
482
626
|
}
|
|
483
627
|
|
|
@@ -485,9 +629,14 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
|
|
|
485
629
|
fun clearSecureBiometric() {
|
|
486
630
|
val inst = getInstanceOrThrow()
|
|
487
631
|
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
632
|
+
synchronized(inst) {
|
|
633
|
+
inst.clearBiometricStores()
|
|
634
|
+
inst.invalidateSecureKeysCache()
|
|
635
|
+
}
|
|
636
|
+
} catch (e: Exception) {
|
|
637
|
+
throw e.wrapStorageException(
|
|
638
|
+
"NitroStorage: Failed to clear biometric storage: ${e.message}",
|
|
639
|
+
)
|
|
491
640
|
}
|
|
492
641
|
}
|
|
493
642
|
}
|
package/app.plugin.js
CHANGED
|
@@ -15,6 +15,8 @@ const FULL_BACKUP_CONTENT_RESOURCE = "@xml/nitro_storage_full_backup_content";
|
|
|
15
15
|
const secureSharedPrefs = [
|
|
16
16
|
"NitroStorageSecure.xml",
|
|
17
17
|
"NitroStorageBiometric.xml",
|
|
18
|
+
"NitroStorageBiometricOrPasscode.xml",
|
|
19
|
+
"NitroStorageBiometricOnly.xml",
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
function sharedPrefsExcludes(indent = " ") {
|
|
@@ -277,23 +277,32 @@ std::function<void()> HybridStorage::addOnChange(
|
|
|
277
277
|
listenerId = nextListenerId_++;
|
|
278
278
|
listeners_[intScope].push_back({listenerId, callback});
|
|
279
279
|
}
|
|
280
|
-
|
|
280
|
+
// Publish the count after the vector mutation so a zero count seen by the
|
|
281
|
+
// lock-free reader always means "vector has no listeners for this scope".
|
|
282
|
+
if (intScope >= 0 && intScope < 3) {
|
|
283
|
+
listenerScopeCounts_[static_cast<size_t>(intScope)].fetch_add(1, std::memory_order_release);
|
|
284
|
+
}
|
|
285
|
+
|
|
281
286
|
std::weak_ptr<HybridStorage> weakSelf = std::dynamic_pointer_cast<HybridStorage>(shared_from_this());
|
|
282
287
|
return [weakSelf, intScope, listenerId]() {
|
|
283
288
|
auto self = weakSelf.lock();
|
|
284
289
|
if (!self) return; // HybridStorage was destroyed — safe no-op
|
|
285
|
-
std::lock_guard<std::mutex> lock(self->listenersMutex_);
|
|
286
|
-
auto& scopeListeners = self->listeners_[intScope];
|
|
287
290
|
bool found = false;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
{
|
|
292
|
+
std::lock_guard<std::mutex> lock(self->listenersMutex_);
|
|
293
|
+
auto& scopeListeners = self->listeners_[intScope];
|
|
294
|
+
for (auto it = scopeListeners.begin(); it != scopeListeners.end(); ++it) {
|
|
295
|
+
if (it->id == listenerId) {
|
|
296
|
+
scopeListeners.erase(it);
|
|
297
|
+
found = true;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
293
300
|
}
|
|
294
301
|
}
|
|
295
302
|
// Silently ignore double-unsubscribe (listener already removed)
|
|
296
|
-
(
|
|
303
|
+
if (found && intScope >= 0 && intScope < 3) {
|
|
304
|
+
self->listenerScopeCounts_[static_cast<size_t>(intScope)].fetch_sub(1, std::memory_order_release);
|
|
305
|
+
}
|
|
297
306
|
};
|
|
298
307
|
}
|
|
299
308
|
|
|
@@ -608,6 +617,13 @@ void HybridStorage::clearSecureBiometric() {
|
|
|
608
617
|
// --- Internal ---
|
|
609
618
|
|
|
610
619
|
std::vector<HybridStorage::Listener> HybridStorage::copyListenersForScope(int scope) {
|
|
620
|
+
// Lock-free fast path: when no listeners are registered for this scope,
|
|
621
|
+
// avoid taking the mutex and copying the (empty) vector on every write.
|
|
622
|
+
if (scope >= 0 && scope < 3 &&
|
|
623
|
+
listenerScopeCounts_[static_cast<size_t>(scope)].load(std::memory_order_acquire) == 0) {
|
|
624
|
+
return {};
|
|
625
|
+
}
|
|
626
|
+
|
|
611
627
|
std::vector<Listener> listenersCopy;
|
|
612
628
|
{
|
|
613
629
|
std::lock_guard<std::mutex> lock(listenersMutex_);
|
|
@@ -656,12 +672,10 @@ void HybridStorage::ensureKeyIndexHydrated(int scope) {
|
|
|
656
672
|
return;
|
|
657
673
|
}
|
|
658
674
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
675
|
+
std::lock_guard<std::mutex> lock(keyIndexMutex_);
|
|
676
|
+
auto hydratedIt = keyIndexHydrated_.find(scope);
|
|
677
|
+
if (hydratedIt != keyIndexHydrated_.end() && hydratedIt->second) {
|
|
678
|
+
return;
|
|
665
679
|
}
|
|
666
680
|
|
|
667
681
|
ensureAdapter();
|
|
@@ -678,12 +692,6 @@ void HybridStorage::ensureKeyIndexHydrated(int scope) {
|
|
|
678
692
|
throw std::runtime_error("NitroStorage: Key index hydration failed (unknown error)");
|
|
679
693
|
}
|
|
680
694
|
|
|
681
|
-
std::lock_guard<std::mutex> lock(keyIndexMutex_);
|
|
682
|
-
// Double-check: another thread may have hydrated while we fetched
|
|
683
|
-
auto hydratedIt = keyIndexHydrated_.find(scope);
|
|
684
|
-
if (hydratedIt != keyIndexHydrated_.end() && hydratedIt->second) {
|
|
685
|
-
return; // discard our results
|
|
686
|
-
}
|
|
687
695
|
auto& index = keyIndex_[scope];
|
|
688
696
|
index.clear();
|
|
689
697
|
for (const auto& key : keys) {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
#include <memory>
|
|
12
12
|
#include <vector>
|
|
13
13
|
#include <unordered_set>
|
|
14
|
+
#include <atomic>
|
|
15
|
+
#include <array>
|
|
14
16
|
|
|
15
17
|
namespace margelo::nitro::NitroStorage {
|
|
16
18
|
|
|
@@ -74,6 +76,9 @@ private:
|
|
|
74
76
|
HybridStorageMap<int, std::vector<Listener>> listeners_;
|
|
75
77
|
std::mutex listenersMutex_;
|
|
76
78
|
size_t nextListenerId_ = 0;
|
|
79
|
+
// Lock-free fast path: skip locking/copying the listener vector on the
|
|
80
|
+
// write/notify path when no listeners are registered for a scope.
|
|
81
|
+
std::array<std::atomic<size_t>, 3> listenerScopeCounts_{};
|
|
77
82
|
HybridStorageMap<int, std::unordered_set<std::string>> keyIndex_;
|
|
78
83
|
HybridStorageMap<int, bool> keyIndexHydrated_;
|
|
79
84
|
std::mutex keyIndexMutex_;
|
package/docs/secure-storage.md
CHANGED
|
@@ -47,6 +47,8 @@ export const recoveryCodeItem = createStorageItem<string>({
|
|
|
47
47
|
|
|
48
48
|
`BiometricLevel.BiometryOnly` does not allow passcode fallback. Use `BiometricLevel.BiometryOrPasscode` when passcode fallback is acceptable.
|
|
49
49
|
|
|
50
|
+
On Android 11 and newer, the two levels use separate Android Keystore keys with distinct allowed authenticators. Android 10 and older support `BiometryOrPasscode`; `BiometryOnly` reports `biometric_unavailable` because the older Keystore API cannot enforce that distinction safely for this storage backend. Android authorization remains valid for a 30-second window after successful authentication.
|
|
51
|
+
|
|
50
52
|
## Access Control
|
|
51
53
|
|
|
52
54
|
`accessControl` maps to platform accessibility rules where available.
|
|
@@ -141,6 +143,8 @@ Android secure storage uses encrypted SharedPreferences. Restored encrypted pref
|
|
|
141
143
|
|
|
142
144
|
- `NitroStorageSecure.xml`
|
|
143
145
|
- `NitroStorageBiometric.xml`
|
|
146
|
+
- `NitroStorageBiometricOrPasscode.xml`
|
|
147
|
+
- `NitroStorageBiometricOnly.xml`
|
|
144
148
|
|
|
145
149
|
If you disable `configureAndroidBackup` or maintain custom Android backup XML, add equivalent exclusions for both cloud backup and device transfer.
|
|
146
150
|
|
package/lib/commonjs/index.js
CHANGED
|
@@ -27,7 +27,7 @@ Object.defineProperty(exports, "createIndexedDBBackend", {
|
|
|
27
27
|
return _indexeddbBackend.createIndexedDBBackend;
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
|
-
exports.createStorageItem = exports.createSecureAuthStorage = void 0;
|
|
30
|
+
exports.diskItem = exports.createStorageItem = exports.createSetItem = exports.createSecureAuthStorage = void 0;
|
|
31
31
|
exports.flushWebStorageBackends = flushWebStorageBackends;
|
|
32
32
|
exports.getBatch = void 0;
|
|
33
33
|
Object.defineProperty(exports, "getStorageErrorCode", {
|
|
@@ -44,13 +44,14 @@ Object.defineProperty(exports, "isKeychainLockedError", {
|
|
|
44
44
|
return _shared.isKeychainLockedError;
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
|
+
exports.memoryItem = void 0;
|
|
47
48
|
Object.defineProperty(exports, "migrateFromMMKV", {
|
|
48
49
|
enumerable: true,
|
|
49
50
|
get: function () {
|
|
50
51
|
return _migration.migrateFromMMKV;
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
|
-
exports.setBatch = exports.runTransaction = exports.removeBatch = exports.registerMigration = exports.migrateToLatest = void 0;
|
|
54
|
+
exports.setBatch = exports.secureItem = exports.runTransaction = exports.removeBatch = exports.registerMigration = exports.migrateToLatest = void 0;
|
|
54
55
|
exports.setWebDiskStorageBackend = setWebDiskStorageBackend;
|
|
55
56
|
exports.setWebSecureStorageBackend = setWebSecureStorageBackend;
|
|
56
57
|
exports.storage = void 0;
|
|
@@ -66,12 +67,24 @@ Object.defineProperty(exports, "useStorage", {
|
|
|
66
67
|
return _storageHooks.useStorage;
|
|
67
68
|
}
|
|
68
69
|
});
|
|
70
|
+
Object.defineProperty(exports, "useStorageActions", {
|
|
71
|
+
enumerable: true,
|
|
72
|
+
get: function () {
|
|
73
|
+
return _storageHooks.useStorageActions;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
69
76
|
Object.defineProperty(exports, "useStorageSelector", {
|
|
70
77
|
enumerable: true,
|
|
71
78
|
get: function () {
|
|
72
79
|
return _storageHooks.useStorageSelector;
|
|
73
80
|
}
|
|
74
81
|
});
|
|
82
|
+
Object.defineProperty(exports, "useStorageValue", {
|
|
83
|
+
enumerable: true,
|
|
84
|
+
get: function () {
|
|
85
|
+
return _storageHooks.useStorageValue;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
75
88
|
var _shared = require("./shared");
|
|
76
89
|
var _reactNativeNitroModules = require("react-native-nitro-modules");
|
|
77
90
|
var _Storage = require("./Storage.types");
|
|
@@ -252,6 +265,10 @@ const storage = exports.storage = {
|
|
|
252
265
|
})
|
|
253
266
|
};
|
|
254
267
|
const createStorageItem = exports.createStorageItem = core.createStorageItem;
|
|
268
|
+
const memoryItem = exports.memoryItem = core.memoryItem;
|
|
269
|
+
const diskItem = exports.diskItem = core.diskItem;
|
|
270
|
+
const secureItem = exports.secureItem = core.secureItem;
|
|
271
|
+
const createSetItem = exports.createSetItem = core.createSetItem;
|
|
255
272
|
const getBatch = exports.getBatch = core.getBatch;
|
|
256
273
|
const setBatch = exports.setBatch = core.setBatch;
|
|
257
274
|
const removeBatch = exports.removeBatch = core.removeBatch;
|