react-native-nitro-storage 0.8.0 → 0.10.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 (78) hide show
  1. package/CHANGELOG.md +107 -25
  2. package/README.md +140 -27
  3. package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +5 -2
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +240 -34
  5. package/cpp/bindings/HybridStorage.cpp +109 -293
  6. package/cpp/bindings/HybridStorage.hpp +17 -10
  7. package/docs/api-reference.md +75 -19
  8. package/docs/benchmarks.md +6 -1
  9. package/docs/keychain-lifecycle-testing.md +36 -0
  10. package/docs/recipes.md +1 -2
  11. package/docs/secure-storage.md +45 -9
  12. package/ios/IOSStorageAdapterCpp.mm +391 -175
  13. package/lib/commonjs/capabilities.js +3 -1
  14. package/lib/commonjs/capabilities.js.map +1 -1
  15. package/lib/commonjs/core/durability.js +110 -43
  16. package/lib/commonjs/core/durability.js.map +1 -1
  17. package/lib/commonjs/index.js +15 -5
  18. package/lib/commonjs/index.js.map +1 -1
  19. package/lib/commonjs/index.web.js +219 -97
  20. package/lib/commonjs/index.web.js.map +1 -1
  21. package/lib/commonjs/internal.js +3 -11
  22. package/lib/commonjs/internal.js.map +1 -1
  23. package/lib/commonjs/shared.js +62 -2
  24. package/lib/commonjs/shared.js.map +1 -1
  25. package/lib/commonjs/storage-core.js +859 -145
  26. package/lib/commonjs/storage-core.js.map +1 -1
  27. package/lib/commonjs/storage-runtime.js +5 -1
  28. package/lib/commonjs/storage-runtime.js.map +1 -1
  29. package/lib/commonjs/testing.js +13 -0
  30. package/lib/commonjs/testing.js.map +1 -1
  31. package/lib/module/capabilities.js +2 -1
  32. package/lib/module/capabilities.js.map +1 -1
  33. package/lib/module/core/durability.js +110 -43
  34. package/lib/module/core/durability.js.map +1 -1
  35. package/lib/module/index.js +12 -8
  36. package/lib/module/index.js.map +1 -1
  37. package/lib/module/index.web.js +215 -99
  38. package/lib/module/index.web.js.map +1 -1
  39. package/lib/module/internal.js +2 -9
  40. package/lib/module/internal.js.map +1 -1
  41. package/lib/module/shared.js +60 -2
  42. package/lib/module/shared.js.map +1 -1
  43. package/lib/module/storage-core.js +860 -146
  44. package/lib/module/storage-core.js.map +1 -1
  45. package/lib/module/storage-runtime.js +4 -1
  46. package/lib/module/storage-runtime.js.map +1 -1
  47. package/lib/module/testing.js +8 -1
  48. package/lib/module/testing.js.map +1 -1
  49. package/lib/typescript/capabilities.d.ts +2 -1
  50. package/lib/typescript/capabilities.d.ts.map +1 -1
  51. package/lib/typescript/core/durability.d.ts +7 -2
  52. package/lib/typescript/core/durability.d.ts.map +1 -1
  53. package/lib/typescript/index.d.ts +2 -2
  54. package/lib/typescript/index.d.ts.map +1 -1
  55. package/lib/typescript/index.web.d.ts +3 -29
  56. package/lib/typescript/index.web.d.ts.map +1 -1
  57. package/lib/typescript/internal.d.ts +0 -2
  58. package/lib/typescript/internal.d.ts.map +1 -1
  59. package/lib/typescript/shared.d.ts +19 -0
  60. package/lib/typescript/shared.d.ts.map +1 -1
  61. package/lib/typescript/storage-core.d.ts +12 -3
  62. package/lib/typescript/storage-core.d.ts.map +1 -1
  63. package/lib/typescript/storage-runtime.d.ts +2 -1
  64. package/lib/typescript/storage-runtime.d.ts.map +1 -1
  65. package/lib/typescript/testing.d.ts +1 -1
  66. package/lib/typescript/testing.d.ts.map +1 -1
  67. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +1 -1
  68. package/package.json +3 -3
  69. package/react-native-nitro-storage.podspec +3 -1
  70. package/src/capabilities.ts +3 -1
  71. package/src/core/durability.ts +139 -39
  72. package/src/index.ts +20 -10
  73. package/src/index.web.ts +328 -166
  74. package/src/internal.ts +0 -14
  75. package/src/shared.ts +85 -0
  76. package/src/storage-core.ts +1165 -199
  77. package/src/storage-runtime.ts +6 -0
  78. package/src/testing.ts +8 -1
@@ -11,6 +11,7 @@ import android.security.keystore.UserNotAuthenticatedException
11
11
  import android.content.SharedPreferences
12
12
  import androidx.security.crypto.EncryptedSharedPreferences
13
13
  import androidx.security.crypto.MasterKey
14
+ import java.io.File
14
15
  import java.security.InvalidKeyException
15
16
  import java.security.KeyStore
16
17
  import java.security.KeyStoreException
@@ -26,6 +27,11 @@ private fun Throwable.hasCause(type: Class<*>): Boolean {
26
27
  }
27
28
 
28
29
  private fun Throwable.storageErrorCode(): String? {
30
+ val taggedCode = message
31
+ ?.let { Regex("\\[nitro-error:([a-z_]+)]").find(it)?.groupValues?.get(1) }
32
+ if (taggedCode == "storage_compensation_failed") {
33
+ return taggedCode
34
+ }
29
35
  return when {
30
36
  hasCause(AEADBadTagException::class.java) -> "storage_corruption"
31
37
  hasCause(UserNotAuthenticatedException::class.java) ||
@@ -40,6 +46,9 @@ private fun Throwable.wrapStorageException(
40
46
  defaultMessage: String,
41
47
  defaultCode: String? = null,
42
48
  ): RuntimeException {
49
+ if (message?.contains("[nitro-error:") == true && this is RuntimeException) {
50
+ return this
51
+ }
43
52
  val code = storageErrorCode() ?: defaultCode
44
53
  val message = if (code != null) {
45
54
  "[nitro-error:$code] $defaultMessage"
@@ -76,28 +85,43 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
76
85
  private val biometricOnlyMasterKeyAlias =
77
86
  "${context.packageName}.nitro_storage.biometric_only_key"
78
87
 
88
+ @Volatile
89
+ private var legacyBiometricInitialized = false
90
+
91
+ @Volatile
92
+ private var biometricOrPasscodeInitialized = false
93
+
94
+ @Volatile
95
+ private var biometricOnlyInitialized = false
96
+
79
97
  private val legacyBiometricPreferences: SharedPreferences by lazy {
80
- createBiometricPreferences(
98
+ val preferences = createBiometricPreferences(
81
99
  "NitroStorageBiometric",
82
100
  biometricMasterKeyAlias,
83
101
  1,
84
102
  )
103
+ legacyBiometricInitialized = true
104
+ preferences
85
105
  }
86
106
 
87
107
  private val biometricOrPasscodePreferences: SharedPreferences by lazy {
88
- createBiometricPreferences(
108
+ val preferences = createBiometricPreferences(
89
109
  "NitroStorageBiometricOrPasscode",
90
110
  biometricOrPasscodeMasterKeyAlias,
91
111
  1,
92
112
  )
113
+ biometricOrPasscodeInitialized = true
114
+ preferences
93
115
  }
94
116
 
95
117
  private val biometricOnlyPreferences: SharedPreferences by lazy {
96
- createBiometricPreferences(
118
+ val preferences = createBiometricPreferences(
97
119
  "NitroStorageBiometricOnly",
98
120
  biometricOnlyMasterKeyAlias,
99
121
  2,
100
122
  )
123
+ biometricOnlyInitialized = true
124
+ preferences
101
125
  }
102
126
 
103
127
  @Volatile
@@ -154,7 +178,13 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
154
178
  ): SharedPreferences {
155
179
  return try {
156
180
  val keyFactory = { createBiometricMasterKey(alias, level) }
157
- initializeEncryptedPreferences(name, keyFactory(), alias, keyFactory)
181
+ val preferences = initializeEncryptedPreferences(
182
+ name,
183
+ keyFactory(),
184
+ alias,
185
+ keyFactory,
186
+ )
187
+ verifyReadablePreferences(preferences, name, alias, keyFactory)
158
188
  } catch (e: Exception) {
159
189
  throw e.wrapStorageException(
160
190
  "NitroStorage: Biometric storage is not available on this device. " +
@@ -164,6 +194,31 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
164
194
  }
165
195
  }
166
196
 
197
+ private fun verifyReadablePreferences(
198
+ preferences: SharedPreferences,
199
+ name: String,
200
+ alias: String,
201
+ keyFactory: () -> MasterKey,
202
+ ): SharedPreferences {
203
+ return try {
204
+ preferences.all
205
+ preferences
206
+ } catch (e: Exception) {
207
+ if (!e.hasCause(AEADBadTagException::class.java)) {
208
+ throw e
209
+ }
210
+ clearCorruptedStorage(name, alias)
211
+ try {
212
+ initializeEncryptedPreferences(name, keyFactory(), alias, keyFactory)
213
+ } catch (retryEx: Exception) {
214
+ throw retryEx.wrapStorageException(
215
+ "NitroStorage: Unrecoverable storage corruption in $name",
216
+ defaultCode = "storage_corruption",
217
+ )
218
+ }
219
+ }
220
+ }
221
+
167
222
  private fun initializeEncryptedPreferences(
168
223
  name: String,
169
224
  key: MasterKey,
@@ -212,23 +267,29 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
212
267
  }
213
268
 
214
269
  private fun preferencesFileExists(name: String): Boolean {
215
- return context.getSharedPreferences(name, Context.MODE_PRIVATE).all.isNotEmpty()
270
+ return File(context.applicationInfo.dataDir, "shared_prefs/$name.xml").exists()
216
271
  }
217
272
 
218
273
  private fun existingBiometricPreferences(): List<SharedPreferences> {
219
274
  val stores = mutableListOf<SharedPreferences>()
220
- if (preferencesFileExists("NitroStorageBiometricOnly")) {
275
+ if (
276
+ biometricOnlyInitialized ||
277
+ preferencesFileExists("NitroStorageBiometricOnly")
278
+ ) {
221
279
  stores.add(biometricOnlyPreferences)
222
280
  }
223
- if (preferencesFileExists("NitroStorageBiometricOrPasscode")) {
281
+ if (
282
+ biometricOrPasscodeInitialized ||
283
+ preferencesFileExists("NitroStorageBiometricOrPasscode")
284
+ ) {
224
285
  stores.add(biometricOrPasscodePreferences)
225
286
  }
226
- if (preferencesFileExists("NitroStorageBiometric")) {
287
+ if (
288
+ legacyBiometricInitialized ||
289
+ preferencesFileExists("NitroStorageBiometric")
290
+ ) {
227
291
  stores.add(legacyBiometricPreferences)
228
292
  }
229
- for (preferences in stores) {
230
- preferences.all
231
- }
232
293
  return stores
233
294
  }
234
295
 
@@ -245,17 +306,19 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
245
306
  private fun removeBiometricKey(
246
307
  key: String,
247
308
  preferencesList: List<SharedPreferences> = existingBiometricPreferences(),
309
+ forceSynchronous: Boolean = false,
248
310
  ) {
249
311
  for (preferences in preferencesList) {
250
- applySecureEditor(preferences.edit().remove(key))
312
+ applySecureEditor(preferences.edit().remove(key), forceSynchronous)
251
313
  }
252
314
  }
253
315
 
254
316
  private fun clearBiometricStores(
255
317
  preferencesList: List<SharedPreferences> = existingBiometricPreferences(),
318
+ forceSynchronous: Boolean = false,
256
319
  ) {
257
320
  for (preferences in preferencesList) {
258
- applySecureEditor(preferences.edit().clear())
321
+ applySecureEditor(preferences.edit().clear(), forceSynchronous)
259
322
  }
260
323
  }
261
324
 
@@ -272,9 +335,12 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
272
335
  }
273
336
  }
274
337
 
275
- private fun applySecureEditor(editor: SharedPreferences.Editor) {
338
+ private fun applySecureEditor(
339
+ editor: SharedPreferences.Editor,
340
+ forceSynchronous: Boolean = false,
341
+ ) {
276
342
  try {
277
- if (secureWritesAsync) {
343
+ if (secureWritesAsync && !forceSynchronous) {
278
344
  editor.apply()
279
345
  } else if (!editor.commit()) {
280
346
  throw IllegalStateException("SharedPreferences commit returned false")
@@ -286,6 +352,108 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
286
352
  }
287
353
  }
288
354
 
355
+ private data class BiometricEntrySnapshot(
356
+ val preferences: SharedPreferences,
357
+ val present: Boolean,
358
+ val value: String?,
359
+ )
360
+
361
+ private data class PlainEntrySnapshot(
362
+ val present: Boolean,
363
+ val value: String?,
364
+ )
365
+
366
+ private fun captureBiometricEntrySnapshots(key: String): List<BiometricEntrySnapshot> {
367
+ return existingBiometricPreferences().map { preferences ->
368
+ val present = preferences.contains(key)
369
+ BiometricEntrySnapshot(
370
+ preferences = preferences,
371
+ present = present,
372
+ value = if (present) getSecureSafe(preferences, key) else null,
373
+ )
374
+ }
375
+ }
376
+
377
+ private fun capturePlainEntrySnapshot(key: String): PlainEntrySnapshot {
378
+ val present = encryptedPreferences.contains(key)
379
+ return PlainEntrySnapshot(
380
+ present = present,
381
+ value = if (present) getSecureSafe(encryptedPreferences, key) else null,
382
+ )
383
+ }
384
+
385
+ private fun restoreBiometricEntrySnapshots(
386
+ key: String,
387
+ snapshots: List<BiometricEntrySnapshot>,
388
+ additionalPreferences: List<SharedPreferences>,
389
+ ): List<Throwable> {
390
+ val errors = mutableListOf<Throwable>()
391
+ val existingPreferences = try {
392
+ existingBiometricPreferences()
393
+ } catch (error: Throwable) {
394
+ errors.add(error)
395
+ emptyList()
396
+ }
397
+ val preferencesToClear = (
398
+ existingPreferences +
399
+ snapshots.map { it.preferences } +
400
+ additionalPreferences
401
+ ).distinct()
402
+ preferencesToClear.forEach { preferences ->
403
+ try {
404
+ applySecureEditor(preferences.edit().remove(key), forceSynchronous = true)
405
+ } catch (error: Throwable) {
406
+ errors.add(error)
407
+ }
408
+ }
409
+ snapshots.filter { it.present }.forEach { snapshot ->
410
+ try {
411
+ val value = snapshot.value
412
+ ?: throw IllegalStateException("Biometric snapshot contained a null value")
413
+ applySecureEditor(
414
+ snapshot.preferences.edit().putString(key, value),
415
+ forceSynchronous = true,
416
+ )
417
+ } catch (error: Throwable) {
418
+ errors.add(error)
419
+ }
420
+ }
421
+ return errors
422
+ }
423
+
424
+ private fun restorePlainEntrySnapshot(
425
+ key: String,
426
+ snapshot: PlainEntrySnapshot,
427
+ ): List<Throwable> {
428
+ return try {
429
+ val editor = if (snapshot.present) {
430
+ val value = snapshot.value
431
+ ?: throw IllegalStateException("Plain snapshot contained a null value")
432
+ encryptedPreferences.edit().putString(key, value)
433
+ } else {
434
+ encryptedPreferences.edit().remove(key)
435
+ }
436
+ applySecureEditor(editor, forceSynchronous = true)
437
+ emptyList()
438
+ } catch (error: Throwable) {
439
+ listOf(error)
440
+ }
441
+ }
442
+
443
+ private fun compositeBiometricMutationError(
444
+ operation: String,
445
+ primary: Throwable,
446
+ rollbackErrors: List<Throwable>,
447
+ ): RuntimeException {
448
+ val composite = RuntimeException(
449
+ "[nitro-error:storage_compensation_failed] NitroStorage: $operation failed; " +
450
+ "rollback_error_count=${rollbackErrors.size}",
451
+ primary,
452
+ )
453
+ rollbackErrors.forEach(composite::addSuppressed)
454
+ return composite
455
+ }
456
+
289
457
  private fun invalidateSecureKeysCache() {
290
458
  synchronized(this) {
291
459
  secureKeysCache = null
@@ -418,7 +586,7 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
418
586
  getInstanceOrThrow().sharedPreferences.edit().clear().apply()
419
587
  }
420
588
 
421
- // --- Secure (sync commit by default, async apply when enabled) ---
589
+ // --- Secure (async apply by default, sync commit when requested) ---
422
590
 
423
591
  @JvmStatic
424
592
  fun setSecure(key: String, value: String) {
@@ -550,29 +718,66 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
550
718
  val inst = getInstanceOrThrow()
551
719
  try {
552
720
  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),
721
+ val previousBiometric = inst.captureBiometricEntrySnapshots(key)
722
+ val previousPlain = inst.capturePlainEntrySnapshot(key)
723
+ val additionalPreferences = mutableListOf<SharedPreferences>()
724
+
725
+ try {
726
+ if (level == 0) {
727
+ inst.removeBiometricKey(
728
+ key,
729
+ previousBiometric.map { it.preferences },
730
+ forceSynchronous = true,
731
+ )
732
+ inst.applySecureEditor(
733
+ inst.encryptedPreferences.edit().putString(key, value),
734
+ forceSynchronous = true,
735
+ )
736
+ } else {
737
+ val targetPreferences = inst.biometricPreferencesForLevel(level)
738
+ additionalPreferences.add(targetPreferences)
739
+ inst.applySecureEditor(
740
+ targetPreferences.edit().putString(key, value),
741
+ forceSynchronous = true,
742
+ )
743
+ for (preferences in previousBiometric.map { it.preferences }) {
744
+ if (preferences !== targetPreferences) {
745
+ inst.applySecureEditor(
746
+ preferences.edit().remove(key),
747
+ forceSynchronous = true,
748
+ )
749
+ }
750
+ }
751
+ inst.applySecureEditor(
752
+ inst.encryptedPreferences.edit().remove(key),
753
+ forceSynchronous = true,
754
+ )
755
+ }
756
+ } catch (primary: Exception) {
757
+ val rollbackErrors = mutableListOf<Throwable>()
758
+ rollbackErrors.addAll(
759
+ inst.restoreBiometricEntrySnapshots(
760
+ key,
761
+ previousBiometric,
762
+ additionalPreferences,
763
+ ),
563
764
  )
564
- inst.applySecureEditor(
565
- inst.encryptedPreferences.edit().remove(key),
765
+ rollbackErrors.addAll(
766
+ inst.restorePlainEntrySnapshot(key, previousPlain),
566
767
  )
567
- for (preferences in biometricPreferences) {
568
- if (preferences !== targetPreferences) {
569
- inst.applySecureEditor(preferences.edit().remove(key))
570
- }
768
+ if (rollbackErrors.isNotEmpty()) {
769
+ throw inst.compositeBiometricMutationError(
770
+ if (level == 0) "Secure demotion" else "Biometric promotion",
771
+ primary,
772
+ rollbackErrors,
773
+ )
571
774
  }
775
+ throw primary
572
776
  }
573
777
  inst.invalidateSecureKeysCache()
574
778
  }
575
779
  } catch (e: Exception) {
780
+ inst.invalidateSecureKeysCache()
576
781
  throw e.wrapStorageException(
577
782
  "NitroStorage: Biometric storage unavailable on this device",
578
783
  defaultCode = "biometric_unavailable",
@@ -630,13 +835,14 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
630
835
  val inst = getInstanceOrThrow()
631
836
  try {
632
837
  synchronized(inst) {
633
- inst.clearBiometricStores()
634
- inst.invalidateSecureKeysCache()
838
+ inst.clearBiometricStores(forceSynchronous = true)
635
839
  }
636
840
  } catch (e: Exception) {
637
841
  throw e.wrapStorageException(
638
842
  "NitroStorage: Failed to clear biometric storage: ${e.message}",
639
843
  )
844
+ } finally {
845
+ inst.invalidateSecureKeysCache()
640
846
  }
641
847
  }
642
848
  }