react-native-nitro-storage 0.6.0 → 0.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 +43 -0
- package/README.md +36 -12
- package/SECURITY.md +2 -2
- package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +225 -76
- package/app.plugin.js +2 -0
- package/cpp/bindings/HybridStorage.cpp +4 -12
- package/docs/api-reference.md +11 -1
- package/docs/batch-transactions-migrations.md +8 -2
- package/docs/benchmarks.md +19 -15
- package/docs/secure-storage.md +4 -0
- package/docs/web-backends.md +18 -1
- package/ios/IOSStorageAdapterCpp.mm +162 -17
- package/lib/commonjs/capabilities.js +26 -0
- package/lib/commonjs/capabilities.js.map +1 -0
- package/lib/commonjs/core/durability.js +137 -0
- package/lib/commonjs/core/durability.js.map +1 -0
- package/lib/commonjs/core/metrics.js +91 -0
- package/lib/commonjs/core/metrics.js.map +1 -0
- package/lib/commonjs/index.js +47 -22
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +118 -66
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/indexeddb-backend.js +73 -23
- package/lib/commonjs/indexeddb-backend.js.map +1 -1
- package/lib/commonjs/internal.js +27 -4
- package/lib/commonjs/internal.js.map +1 -1
- package/lib/commonjs/shared.js +2 -2
- package/lib/commonjs/shared.js.map +1 -1
- package/lib/commonjs/storage-core.js +217 -233
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/commonjs/storage-platform.js +6 -0
- package/lib/commonjs/storage-platform.js.map +1 -0
- package/lib/commonjs/storage-runtime.js +3 -21
- package/lib/commonjs/storage-runtime.js.map +1 -1
- package/lib/commonjs/testing.js +19 -4
- package/lib/commonjs/testing.js.map +1 -1
- package/lib/commonjs/web-backend-contract.js +27 -0
- package/lib/commonjs/web-backend-contract.js.map +1 -0
- package/lib/module/capabilities.js +21 -0
- package/lib/module/capabilities.js.map +1 -0
- package/lib/module/core/durability.js +133 -0
- package/lib/module/core/durability.js.map +1 -0
- package/lib/module/core/metrics.js +87 -0
- package/lib/module/core/metrics.js.map +1 -0
- package/lib/module/index.js +48 -23
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +103 -64
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/indexeddb-backend.js +73 -23
- package/lib/module/indexeddb-backend.js.map +1 -1
- package/lib/module/internal.js +24 -3
- package/lib/module/internal.js.map +1 -1
- package/lib/module/shared.js +2 -2
- package/lib/module/shared.js.map +1 -1
- package/lib/module/storage-core.js +218 -234
- package/lib/module/storage-core.js.map +1 -1
- package/lib/module/storage-platform.js +4 -0
- package/lib/module/storage-platform.js.map +1 -0
- package/lib/module/storage-runtime.js +3 -21
- package/lib/module/storage-runtime.js.map +1 -1
- package/lib/module/testing.js +5 -3
- package/lib/module/testing.js.map +1 -1
- package/lib/module/web-backend-contract.js +22 -0
- package/lib/module/web-backend-contract.js.map +1 -0
- package/lib/typescript/capabilities.d.ts +11 -0
- package/lib/typescript/capabilities.d.ts.map +1 -0
- package/lib/typescript/core/durability.d.ts +27 -0
- package/lib/typescript/core/durability.d.ts.map +1 -0
- package/lib/typescript/core/metrics.d.ts +12 -0
- package/lib/typescript/core/metrics.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +11 -3
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +14 -5
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
- package/lib/typescript/internal.d.ts +3 -0
- package/lib/typescript/internal.d.ts.map +1 -1
- package/lib/typescript/shared.d.ts +2 -1
- package/lib/typescript/shared.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts +14 -4
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/storage-events.d.ts +2 -2
- package/lib/typescript/storage-events.d.ts.map +1 -1
- package/lib/typescript/storage-platform.d.ts +12 -0
- package/lib/typescript/storage-platform.d.ts.map +1 -0
- package/lib/typescript/storage-runtime.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts +17 -3
- package/lib/typescript/testing.d.ts.map +1 -1
- package/lib/typescript/web-backend-contract.d.ts +10 -0
- package/lib/typescript/web-backend-contract.d.ts.map +1 -0
- package/lib/typescript/web-storage-backend.d.ts +1 -1
- package/lib/typescript/web-storage-backend.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/capabilities.ts +36 -0
- package/src/core/durability.ts +170 -0
- package/src/core/metrics.ts +133 -0
- package/src/index.ts +67 -42
- package/src/index.web.ts +159 -88
- package/src/indexeddb-backend.ts +92 -22
- package/src/internal.ts +51 -5
- package/src/shared.ts +7 -6
- package/src/storage-core.ts +336 -287
- package/src/storage-events.ts +3 -3
- package/src/storage-platform.ts +16 -0
- package/src/storage-runtime.ts +13 -47
- package/src/testing.ts +14 -7
- package/src/web-backend-contract.ts +34 -0
- package/src/web-storage-backend.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format follows Keep a Changelog and the project adheres to SemVer.
|
|
6
6
|
|
|
7
|
+
## 0.8.0 - 2026-08-12
|
|
8
|
+
|
|
9
|
+
### Breaking changes
|
|
10
|
+
|
|
11
|
+
- None. `getMetricsSnapshot()` keeps its unscoped, cross-scope aggregate keys.
|
|
12
|
+
Use the new `getScopedMetricsSnapshot()` when per-scope counters are needed.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Exported `PlatformStorage` and `PlatformScope` types from the native, web,
|
|
17
|
+
and testing entrypoints so shared consumer code can verify platform parity
|
|
18
|
+
without duplicating the package contract.
|
|
19
|
+
- Added `getScopedMetricsSnapshot()` with keys such as `item:set:1`, without
|
|
20
|
+
changing established dashboards that consume `getMetricsSnapshot()`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Data-loss prevention:** `storage.import()` now flushes pending coalesced Disk and Secure writes before writing, so a later scheduled flush can never overwrite imported values.
|
|
25
|
+
- **Biometric parity:** promoting a value to biometric storage removes the plain secure copy on iOS and web, matching Android; plain reads can no longer return stale values after promotion.
|
|
26
|
+
- **iOS legacy disk data:** Disk enumeration, size, prefix queries, and clear now cover both the suite domain and legacy `standardUserDefaults` values, so deleted legacy values cannot reappear.
|
|
27
|
+
- **Failure-atomic migrations:** each migration step runs in its own transaction with its version marker; a failed step rolls back its data and marker, and rerunning `migrateToLatest()` retries from the last completed version.
|
|
28
|
+
- **Transaction events:** failed transactions emit exactly one typed `rollback` batch event with pre-rollback and restored raw values.
|
|
29
|
+
- **Atomic memory batch removes:** `removeBatch()` in Memory scope mutates all keys first and emits a single `removeBatch` event.
|
|
30
|
+
- **Stable error classification:** storage error codes now come only from `[nitro-error:<code>]` tags produced by the native and web adapters; message-text scraping was removed and every public code has a producer.
|
|
31
|
+
- **Encoding collisions:** reserved primitive tokens and the native batch missing sentinel are escaped in the stored encoding; legacy reads are preserved and raw API round-trips are unchanged.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `getCapabilities().writeBuffering` now reports real per-mode durability: native Secure writes report buffering only while `setSecureWritesAsync(true)` is active on Android, and web backends report buffering only for IndexedDB-based backends.
|
|
36
|
+
- The IndexedDB backend reports affected keys when `flush()` fails and starts a best-effort flush on `pagehide` and hidden visibility changes.
|
|
37
|
+
- `setIfVersion()` is documented as optimistic (no backend-level atomicity); CAS guarantees are covered by race tests.
|
|
38
|
+
|
|
39
|
+
## 0.7.0 - 2026-07-30
|
|
40
|
+
|
|
41
|
+
### Changes
|
|
42
|
+
|
|
43
|
+
- **Breaking change:** Android secure key discovery, existence checks, and cleanup now surface locked, unavailable, or invalidated biometric-store errors instead of treating inaccessible protected values as absent. Catch storage errors around these operations and use `isKeychainLockedError()` when retrying after device authentication is appropriate.
|
|
44
|
+
- Upgrade the validated package baseline to Expo SDK 57, React Native 0.86.2, and Nitro Modules/Nitrogen 0.36.4.
|
|
45
|
+
- Preserve each item/value relationship in heterogeneous `setBatch()` calls so TypeScript rejects values assigned to the wrong storage item.
|
|
46
|
+
- Serialize native key-index hydration with concurrent mutations so `has`, `size`, and key queries cannot remain stale after a racing write.
|
|
47
|
+
- Enforce Android biometric policy levels with distinct Keystore keys, propagate locked or invalidated biometric failures, and keep secure preference files excluded from backup.
|
|
48
|
+
- Preflight biometric store access before aggregate secure mutations and surface native commit or corruption-recovery failures.
|
|
49
|
+
|
|
7
50
|
## 0.6.0 - 2026-06-15
|
|
8
51
|
|
|
9
52
|
### Added
|
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/react-native-nitro-storage)
|
|
5
5
|
[](https://github.com/JoaoPauloCMarra/react-native-nitro-storage/actions/workflows/ci.yml)
|
|
6
6
|
[](https://github.com/JoaoPauloCMarra/react-native-nitro-storage/blob/main/LICENSE)
|
|
7
|
-
[](https://reactnative.dev/)
|
|
8
|
-
[](https://reactnative.dev/docs/0.86/getting-started-without-a-framework)
|
|
8
|
+
[](https://docs.expo.dev/versions/v57.0.0/)
|
|
9
|
+
[](https://nitro.margelo.com/)
|
|
10
10
|
[](https://www.typescriptlang.org/)
|
|
11
11
|
|
|
12
12
|
Synchronous Memory, Disk, and Secure storage for React Native, Expo development
|
|
@@ -53,13 +53,16 @@ bun add react-native-nitro-storage react-native-nitro-modules
|
|
|
53
53
|
|
|
54
54
|
Peer dependencies:
|
|
55
55
|
|
|
56
|
-
| Package | Version
|
|
57
|
-
| ---------------------------- |
|
|
58
|
-
| `react` | `>=18.2.0`
|
|
59
|
-
| `react-native` | `>=0.75.0`
|
|
60
|
-
| `react-native-nitro-modules` | `>=0.
|
|
56
|
+
| Package | Version |
|
|
57
|
+
| ---------------------------- | ------------------ |
|
|
58
|
+
| `react` | `>=18.2.0` |
|
|
59
|
+
| `react-native` | `>=0.75.0` |
|
|
60
|
+
| `react-native-nitro-modules` | `>=0.36.5 <0.37.0` |
|
|
61
61
|
|
|
62
|
-
Nitro peer requirement: `react-native-nitro-modules >=0.
|
|
62
|
+
Nitro peer requirement: `react-native-nitro-modules >=0.36.5 <0.37.0`.
|
|
63
|
+
|
|
64
|
+
Validated example baseline: Expo SDK 57, React Native 0.86.2, React 19.2.3,
|
|
65
|
+
and Nitro Modules 0.36.5.
|
|
63
66
|
|
|
64
67
|
For Expo development builds:
|
|
65
68
|
|
|
@@ -180,7 +183,7 @@ const config = diskItem<{ theme: "light" | "dark"; compact: boolean }>({
|
|
|
180
183
|
});
|
|
181
184
|
|
|
182
185
|
config.merge({ compact: true }); // shallow object update
|
|
183
|
-
config.reset(); //
|
|
186
|
+
config.reset(); // deletes the stored key; the next read returns the default value
|
|
184
187
|
const loginMethod = memoryItem<string | null>({
|
|
185
188
|
key: "loginMethod",
|
|
186
189
|
defaultValue: null,
|
|
@@ -371,10 +374,22 @@ exporting secure values unless you are intentionally doing a short-lived
|
|
|
371
374
|
in-memory migration. `storage.export(StorageScope.Secure)` throws unless you
|
|
372
375
|
explicitly opt into `{ includeSecureValues: true }`.
|
|
373
376
|
|
|
377
|
+
On Android 11 and newer, `BiometricLevel.BiometryOnly` and
|
|
378
|
+
`BiometricLevel.BiometryOrPasscode` use separate Keystore policies. Android 10
|
|
379
|
+
and older support `BiometryOrPasscode`; `BiometryOnly` throws
|
|
380
|
+
`biometric_unavailable` because those releases cannot safely enforce the
|
|
381
|
+
biometric-only distinction. Promoting a value to biometric storage removes the
|
|
382
|
+
plain secure copy on every platform, so plain reads cannot return a stale
|
|
383
|
+
value. Secure existence, discovery, and cleanup operations
|
|
384
|
+
can also throw when a protected store is locked or its key is invalidated. Catch
|
|
385
|
+
those failures and use `isKeychainLockedError()` when authentication-aware retry
|
|
386
|
+
behavior is appropriate.
|
|
387
|
+
|
|
374
388
|
## Batch Operations
|
|
375
389
|
|
|
376
390
|
`getBatch()` preserves tuple value types, so IDEs infer each result from the
|
|
377
|
-
matching item.
|
|
391
|
+
matching item. `setBatch()` validates every item/value pair independently,
|
|
392
|
+
including heterogeneous batches.
|
|
378
393
|
|
|
379
394
|
```ts
|
|
380
395
|
import { getBatch, removeBatch, setBatch } from "react-native-nitro-storage";
|
|
@@ -418,10 +433,15 @@ storage.setMetricsObserver((event) => {
|
|
|
418
433
|
});
|
|
419
434
|
|
|
420
435
|
const metrics = storage.getMetricsSnapshot();
|
|
436
|
+
const scopedMetrics = storage.getScopedMetricsSnapshot();
|
|
421
437
|
storage.resetMetrics();
|
|
422
438
|
unsubscribe();
|
|
423
439
|
```
|
|
424
440
|
|
|
441
|
+
`getMetricsSnapshot()` aggregates each operation across scopes for backward
|
|
442
|
+
compatibility. `getScopedMetricsSnapshot()` adds the numeric scope suffix for
|
|
443
|
+
per-scope analysis, for example `item:set:1`.
|
|
444
|
+
|
|
425
445
|
Secure event observer values are redacted by default. Pass
|
|
426
446
|
`{ redactSecureValues: false }` only in trusted debug tooling where raw values
|
|
427
447
|
are safe to inspect.
|
|
@@ -471,7 +491,11 @@ migrateFromMMKV(mmkvInstance, themeItem);
|
|
|
471
491
|
```
|
|
472
492
|
|
|
473
493
|
`runTransaction(scope, callback)` rolls back every write made through the `tx`
|
|
474
|
-
context if the callback throws.
|
|
494
|
+
context if the callback throws, then emits one typed `rollback` batch event.
|
|
495
|
+
|
|
496
|
+
Each migration step runs in its own transaction with its version marker, so a
|
|
497
|
+
failed step leaves the scope on the last completed version and rerunning
|
|
498
|
+
`migrateToLatest()` retries deterministically.
|
|
475
499
|
|
|
476
500
|
## Web Backends
|
|
477
501
|
|
package/SECURITY.md
CHANGED
|
@@ -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 = " ") {
|
|
@@ -672,12 +672,10 @@ void HybridStorage::ensureKeyIndexHydrated(int scope) {
|
|
|
672
672
|
return;
|
|
673
673
|
}
|
|
674
674
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
675
|
+
std::lock_guard<std::mutex> lock(keyIndexMutex_);
|
|
676
|
+
auto hydratedIt = keyIndexHydrated_.find(scope);
|
|
677
|
+
if (hydratedIt != keyIndexHydrated_.end() && hydratedIt->second) {
|
|
678
|
+
return;
|
|
681
679
|
}
|
|
682
680
|
|
|
683
681
|
ensureAdapter();
|
|
@@ -694,12 +692,6 @@ void HybridStorage::ensureKeyIndexHydrated(int scope) {
|
|
|
694
692
|
throw std::runtime_error("NitroStorage: Key index hydration failed (unknown error)");
|
|
695
693
|
}
|
|
696
694
|
|
|
697
|
-
std::lock_guard<std::mutex> lock(keyIndexMutex_);
|
|
698
|
-
// Double-check: another thread may have hydrated while we fetched
|
|
699
|
-
auto hydratedIt = keyIndexHydrated_.find(scope);
|
|
700
|
-
if (hydratedIt != keyIndexHydrated_.end() && hydratedIt->second) {
|
|
701
|
-
return; // discard our results
|
|
702
|
-
}
|
|
703
695
|
auto& index = keyIndex_[scope];
|
|
704
696
|
index.clear();
|
|
705
697
|
for (const auto& key : keys) {
|
package/docs/api-reference.md
CHANGED
|
@@ -152,7 +152,7 @@ storage.setEventObserver((event) => {
|
|
|
152
152
|
|
|
153
153
|
`setEventObserver()` redacts Secure `oldValue` and `newValue` fields by default. Pass `{ redactSecureValues: false }` only for in-memory debugging paths that never persist logs. Raw `subscribe*()` APIs preserve values for state integrations.
|
|
154
154
|
|
|
155
|
-
Local batch APIs emit one `type: "batch"` envelope to scope and prefix/namespace listeners. Key subscribers receive the matching per-key change so direct key integrations do not need to unpack batch envelopes. Secure events can include raw secret values; do not log Secure event payloads in production.
|
|
155
|
+
Local batch APIs emit one `type: "batch"` envelope to scope and prefix/namespace listeners. Key subscribers receive the matching per-key change so direct key integrations do not need to unpack batch envelopes. Failed transactions emit one batch envelope with `operation: "rollback"` whose changes carry the pre-rollback and restored raw values. Secure events can include raw secret values; do not log Secure event payloads in production.
|
|
156
156
|
|
|
157
157
|
## Batch Operations
|
|
158
158
|
|
|
@@ -280,5 +280,15 @@ Common public types:
|
|
|
280
280
|
- `WebSecureStorageBackend`
|
|
281
281
|
- `WebStorageChangeEvent`
|
|
282
282
|
- `WebStorageScope`
|
|
283
|
+
- `PlatformStorage`
|
|
284
|
+
- `PlatformScope`
|
|
285
|
+
- `WebBackendCapabilities`
|
|
286
|
+
|
|
287
|
+
`getCapabilities().writeBuffering` describes real per-mode durability:
|
|
288
|
+
|
|
289
|
+
- Native: Disk writes are buffered by the platform (`SharedPreferences.apply()` on Android, `NSUserDefaults` on iOS). Secure writes are buffered only when `setSecureWritesAsync(true)` is active on Android; iOS Keychain writes are synchronous.
|
|
290
|
+
- Web: buffering follows the configured backend; IndexedDB backends are buffered, localStorage backends are synchronous.
|
|
291
|
+
|
|
292
|
+
`describeWebBackendCapabilities(backend)` reports a backend's `buffered`, `flushable`, `closable`, and `subscribable` capabilities from the same typed contract used by the built-in backends.
|
|
283
293
|
|
|
284
294
|
The IndexedDB subpath exports `createIndexedDBBackend()` and `IndexedDBBackendOptions`.
|