@okint-digital/okint-rn-storage 0.7.1 → 0.8.1

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/README.md CHANGED
@@ -1,240 +1,275 @@
1
- # @okint-digital/okint-rn-storage
2
-
3
- > One async storage API for React Native. Swappable backends — hardware
4
- > **Keystore/Keychain** for secrets, **SharedPreferences/UserDefaults** for plain
5
- > data, or **in-memory** for ephemerals. No third-party runtime dependencies.
6
-
7
- Built by **Okint**. Designed to be the simple, dependable storage layer you reach
8
- for instead of juggling `react-native-keychain` + `react-native-encrypted-storage`
9
- + `async-storage` + an MMKV wrapper.
10
-
11
- ## Why
12
-
13
- - **One API, many backends.** Choose per data sensitivity at init — same calls everywhere.
14
- - **Secrets in hardware.** `secure` keeps the encryption key in the Android Keystore /
15
- iOS Keychain (hardware-backed where available). Right home for JWTs, refresh & FCM tokens.
16
- - **Vanilla.** Zero third-party dependencies — JS or native. The native module is
17
- ours (Kotlin + Objective-C); all crypto is the platform's own (`javax.crypto` +
18
- AndroidKeystore / CommonCrypto + Keychain). Nothing to audit but us.
19
- - **Typed & async.** Promise-based, fully typed, with JSON / number / boolean helpers.
20
-
21
- ## Install
22
-
23
- ```sh
24
- npm install @okint-digital/okint-rn-storage
25
- # iOS
26
- cd ios && pod install
27
- # Android: autolinked. Rebuild the app.
28
- ```
29
-
30
- Requires React Native 0.73+ (AGP 8 `namespace`, Java 17). Works on the legacy
31
- and the New Architecture (via the interop layer).
32
-
33
- ## Usage
34
-
35
- ```ts
36
- import { createStorage } from '@okint-digital/okint-rn-storage';
37
-
38
- // Secrets → hardware-backed Keystore / Keychain
39
- const auth = createStorage({ backend: 'secure', namespace: 'auth' });
40
- await auth.setString('refreshToken', token);
41
- const token = await auth.getString('refreshToken');
42
- await auth.setItem('fcm', { token: t, platform: 'android' }); // JSON helper
43
-
44
- // High-value secrets → require Face ID / fingerprint / passcode to access.
45
- // Opt in per use case; the OS shows the auth prompt on read.
46
- const wallet = createStorage({ backend: 'secure', namespace: 'wallet', requireAuth: true });
47
- await wallet.setString('privateKey', pk);
48
- const pk = await wallet.getString('privateKey'); // ← triggers the biometric prompt
49
-
50
- // Plain persistent data → SharedPreferences / UserDefaults
51
- const prefs = createStorage({ backend: 'async', namespace: 'prefs' });
52
- await prefs.setBoolean('onboarded', true);
53
-
54
- // Ephemeral / tests → in-memory, zero native
55
- const cache = createStorage({ backend: 'memory' });
56
-
57
- // SYNCHRONOUS store (the MMKV-style use case) — load once, then sync everywhere.
58
- import { createSyncStorage } from '@okint-digital/okint-rn-storage';
59
- const fast = await createSyncStorage({ backend: 'fast', namespace: 'app' });
60
- fast.setBoolean('onboarded', true); // sync write (persists in background)
61
- const onboarded = fast.getBoolean('onboarded'); // sync read — no await
62
- await fast.flush(); // optional: guarantee durability
63
- ```
64
-
65
- ### API
66
-
67
- Every instance implements:
68
-
69
- | Method | Notes |
70
- |---|---|
71
- | `getString / setString` | raw strings |
72
- | `getItem<T> / setItem<T>` | JSON (throws `PARSE_ERROR` on malformed read) |
73
- | `getNumber / setNumber` | numbers |
74
- | `getBoolean / setBoolean` | booleans |
75
- | `has(key)` | presence check |
76
- | `remove(key)` · `clear()` · `keys()` | |
77
- | `multiGet / multiSet / multiRemove` | batched string ops |
78
- | `backend` | the backing `BackendKind` |
79
-
80
- All methods return Promises and **reject** (never throw synchronously) on
81
- invalid input. `namespace` partitions stores so they never collide.
82
-
83
- ### Input validation
84
-
85
- - **Namespace** becomes a file/service name restricted to `[A-Za-z0-9._-]`
86
- (1–200 chars); `../`, `/`, spaces, etc. are rejected (`INVALID_NAMESPACE`) to
87
- prevent filename injection / cross-store collisions.
88
- - **Keys** must be non-empty strings without control characters (`INVALID_KEY`).
89
- - **`setNumber`** rejects `NaN`/`±Infinity` (they don't round-trip). Numbers are
90
- IEEE-754 doubles for integers above 2^53 (e.g. snowflake IDs) use `setString`.
91
- - **`setItem`** rejects non-JSON-serializable values (`undefined`, functions,
92
- symbols, circular refs, BigInt) with `INVALID_VALUE` instead of corrupting.
93
- - **`getBoolean`** is strict: only canonical `"true"`/`"false"` map; anything
94
- else returns `null`.
95
-
96
- ## Backends
97
-
98
- | Kind | Android | iOS | Encrypted | Use for |
99
- |---|---|---|---|---|
100
- | `secure` | AES-256-GCM (Keystore key) over SharedPreferences | Keychain (`kSecClassGenericPassword`) | ✅ hardware | JWTs, refresh/FCM tokens, secrets |
101
- | `async` | SharedPreferences | UserDefaults suite | ❌ | large / non-sensitive data |
102
- | `memory` | — (pure JS) | — (pure JS) | n/a | ephemeral cache, tests |
103
- | `fast` (sync) | SharedPreferences snapshot | UserDefaults snapshot | ❌ | **synchronous** state/flags/cache (MMKV-style) — via `createSyncStorage` |
104
- | `encrypted` | AES-256-GCM keys **and** values (Keystore key) over SQLite | AES-256-CBC + HMAC-SHA256 keys **and** values (key in Keychain) over SQLite | hardware-rooted key | large encrypted blobs / encrypted DB |
105
- | `sqlite` | SQLite key/value table | SQLite (`sqlite3`) key/value table | ❌ | larger datasets, SQL-backed key/value |
106
-
107
- All five backends are implemented. `encrypted` is a genuinely encrypted
108
- database: **both keys and values** are sealed with an authenticated cipher whose
109
- key is rooted in the hardware Keystore/Keychain, and rows are looked up by a
110
- deterministic HMAC token so nothing readable touches disk, yet it still scales
111
- to large blobs and many entries. No SQLCipher dependency.
112
-
113
- ### Synchronous (`fast`) store
114
-
115
- `createStorage` is async (correct for `secure` never block the UI thread on
116
- Keystore crypto). For the MMKV-style **synchronous** need persist/rehydrate,
117
- feature flags, hot-path UI state use `createSyncStorage`:
118
-
119
- - It loads a snapshot **once**, then every `get`/`set` is **synchronous** in-JS
120
- memory (the fastest possible read path — no per-call bridge crossing).
121
- - Writes apply to memory immediately and persist in the background, **coalesced**
122
- per key; call `flush()` (e.g. on app background) for a durability barrier.
123
- - **Zero-load variant**`createSyncStorageSync` hydrates the snapshot in a
124
- single blocking native bulk-read and returns synchronously, so state is
125
- available immediately at startup (e.g. before first render):
126
-
127
- ```ts
128
- import { createSyncStorageSync } from '@okint-digital/okint-rn-storage';
129
- const fast = createSyncStorageSync({ backend: 'fast', namespace: 'app' });
130
- const onboarded = fast.getBoolean('onboarded'); // sync, no await, no load step
131
- ```
132
-
133
- - **JSI engine** — `createJSIStorage` installs a C++ `jsi::HostObject` and runs
134
- every `get`/`set` **directly in C++ with no bridge serialization** — the
135
- maximum-performance synchronous path, with no JS-memory snapshot:
136
-
137
- ```ts
138
- import { createJSIStorage } from '@okint-digital/okint-rn-storage';
139
- const kv = createJSIStorage({ namespace: 'app' });
140
- kv.setString('theme', 'dark'); // sync, in C++
141
- const theme = kv.getString('theme'); // sync, in C++
142
- ```
143
-
144
- It installs lazily on first use and throws a clear error under remote JS
145
- debugging (no JSI runtime) fall back to `createSyncStorageSync` there.
146
-
147
- > **Opt-in native build.** The C++/JSI engine needs the NDK + CMake + the
148
- > `ReactAndroid::jsi` prefab, so it's **off by default** the package builds
149
- > as pure Kotlin/Java and every other store works without it. Enable it on
150
- > **Android** with `-PokintEnableJSI=true` (or `okintEnableJSI=true` in
151
- > `android/gradle.properties`); on **iOS** it's always compiled in. When
152
- > disabled, `createJSIStorage` reports the engine unavailable — use
153
- > `createSyncStorageSync` (still synchronous, no NDK).
154
-
155
- Use `secure` for tokens — never a sync store.
156
-
157
- ## Compared to alternatives
158
-
159
- | | okint-rn-storage | react-native-keychain | react-native-encrypted-storage | expo-secure-store | react-native-mmkv | async-storage |
160
- |---|---|---|---|---|---|---|
161
- | Secure (hardware-backed) | ✅ | ✅ | ✅ | ✅ | ❌ (key in JS) | ❌ |
162
- | Plain persistent store | (`async`) | | | | ✅ | ✅ |
163
- | Synchronous access | ✅ (`fast` snapshot · zero-load · **C++/JSI**) | | | ❌ | ✅ (mmap) | ❌ |
164
- | In-memory / test backend | | | | ❌ | ❌ | ❌ |
165
- | One API, swappable backends | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
166
- | New Architecture (RN 0.81) | (interop) | (TurboModule) | ❌ unmaintained | ✅ | ✅ (v3 requires it) | ✅ |
167
- | Android crash-recovery¹ | | partial | ❌ | n/a | n/a | n/a |
168
- | Third-party runtime deps | none | none | none | Expo modules | MMKV (C++) | — |
169
- | Maintained (2026) | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
170
-
171
- ¹ Encrypted Android stores can break after backup/restore, device transfer, or
172
- Keystore key invalidation — historically a **startup crash** (the gap that sank
173
- `react-native-encrypted-storage`, which wrapped EncryptedSharedPreferences). okint
174
- never crashes on this: a value that can't be decrypted with the current Keystore
175
- key simply reads back as `null`, so the app re-authenticates instead of dying on
176
- launch.
177
-
178
- **When to use what:** secrets/tokens `secure` (async, hardware-backed). Big or
179
- non-sensitive data `async`. Synchronous state/flags/cache `fast` (via
180
- `createSyncStorage`) this is okint's MMKV replacement, so you don't need a
181
- separate sync library. Tests/ephemeral `memory`. One package, every store.
182
-
183
- ## Errors
184
-
185
- All failures throw `OkintStorageError` with a stable `code`:
186
- `NATIVE_MODULE_MISSING` · `BACKEND_NOT_IMPLEMENTED` · `UNKNOWN_BACKEND` ·
187
- `PARSE_ERROR` · `INVALID_VALUE` · `NATIVE_ERROR`.
188
-
189
- ```ts
190
- import { OkintStorageError } from '@okint-digital/okint-rn-storage';
191
- try { await auth.getItem('x'); }
192
- catch (e) { if (e instanceof OkintStorageError && e.code === 'PARSE_ERROR') { /* … */ } }
193
- ```
194
-
195
- ## Security & reliability
196
-
197
- - **Android `secure`** encrypts every value with **AES-256-GCM** under a
198
- per-namespace, non-exportable **AndroidKeystore** key, preferring the dedicated
199
- **StrongBox** secure element (Titan M / SE) and falling back to the TEE; ciphertext
200
- is held in plain SharedPreferences. This is the same construction
201
- `EncryptedSharedPreferences` used internally without the now-deprecated
202
- `androidx.security:security-crypto`, and with **no third-party dependency** (Tink,
203
- DataStore, etc.). A failed decrypt (restored backup, invalidated key) returns
204
- `null` rather than crashing on launch.
205
- - **Biometric / device-credential gating (`requireAuth`)** — opt-in per secure
206
- store. iOS binds the Keychain item to the **Secure Enclave** via `SecAccessControl`
207
- (`.userPresence` Face ID / Touch ID *or* passcode); the OS prompts automatically
208
- on read. Android (API 28+) marks the AES key `setUserAuthenticationRequired` and
209
- routes every read/write through a framework **`BiometricPrompt`** bound to the
210
- operation's `Cipher` (strong biometric; per-operation). With no enrolled
211
- authenticator, or on API < 28, gated calls reject rather than silently
212
- downgrading. Off by default nothing prompts unless you ask for it.
213
- - **iOS `secure`** uses the Keychain with
214
- `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (not iCloud-synced, not in
215
- encrypted backups, available to background tasks after first unlock) + the
216
- data-protection keychain. Writes are add-or-update (`SecItemUpdate`
217
- `SecItemAdd`). The module is Objective-C for maximum build compatibility (no
218
- Swift / `use_frameworks!` pitfalls).
219
- - **`encrypted`** authenticates as well as encrypts, and seals **both keys and
220
- values**: Android AES-256-GCM (per-namespace Keystore key); iOS AES-256-CBC +
221
- HMAC-SHA256 encrypt-then-MAC (96-byte key in the Keychain, constant-time MAC
222
- check). Rows are addressed by a deterministic HMAC token, so the database holds
223
- no readable key or value, yet scales to large blobs and many entries.
224
- - Keychain/Keystore are sized for secrets, not megabytes. Store tokens & keys in
225
- `secure`; store bulk data in `async`, or encrypted bulk data in `encrypted`.
226
- - **Secrets are never logged** (avoids the class of bug behind CVE-2024-21668 in
227
- another RN storage lib). Error messages carry key names + OS status codes only.
228
-
229
- ### Threat model (read this)
230
-
231
- Hardware-backed Keystore/Keychain protects secrets **at rest on an uncompromised
232
- device**. It does **not** protect against: rooted/jailbroken devices, runtime
233
- instrumentation (Frida) or memory dumps of a running app, malware running as the
234
- same app, or a handed-over unlocked device. For high-value secrets, pair okint
235
- with root/jailbreak detection and short-lived tokens. okint encrypts on Android
236
- by **default** (unlike libraries that fall back to plaintext SharedPreferences).
237
-
238
- ## License
239
-
240
- MIT © Okint Digital see [LICENSE](./LICENSE).
1
+ # @okint-digital/okint-rn-storage
2
+
3
+ > One async storage API for React Native. Swappable backends — hardware
4
+ > **Keystore/Keychain** for secrets, **SharedPreferences/UserDefaults** for plain
5
+ > data, or **in-memory** for ephemerals. No third-party runtime dependencies.
6
+
7
+ Built by **Okint**. Designed to be the simple, dependable storage layer you reach
8
+ for instead of juggling `react-native-keychain` + `react-native-encrypted-storage`
9
+ + `async-storage` + an MMKV wrapper.
10
+
11
+ ## Why
12
+
13
+ - **One API, many backends.** Choose per data sensitivity at init — same calls everywhere.
14
+ - **Secrets in hardware.** `secure` keeps the encryption key in the Android Keystore /
15
+ iOS Keychain (hardware-backed where available). Right home for JWTs, refresh & FCM tokens.
16
+ - **Vanilla.** Zero third-party dependencies — JS or native. The native module is
17
+ ours (Kotlin + Objective-C); all crypto is the platform's own (`javax.crypto` +
18
+ AndroidKeystore / CommonCrypto + Keychain). Nothing to audit but us.
19
+ - **Typed & async.** Promise-based, fully typed, with JSON / number / boolean helpers.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install @okint-digital/okint-rn-storage
25
+ # iOS
26
+ cd ios && pod install
27
+ # Android: autolinked. Rebuild the app.
28
+ ```
29
+
30
+ Requires React Native 0.73+ (AGP 8 `namespace`, Java 17). Works on the legacy
31
+ and the New Architecture (via the interop layer).
32
+
33
+ ## Usage
34
+
35
+ ```ts
36
+ import { createStorage } from '@okint-digital/okint-rn-storage';
37
+
38
+ // Secrets → hardware-backed Keystore / Keychain
39
+ const auth = createStorage({ backend: 'secure', namespace: 'auth' });
40
+ await auth.setString('refreshToken', token);
41
+ const token = await auth.getString('refreshToken');
42
+ await auth.setItem('fcm', { token: t, platform: 'android' }); // JSON helper
43
+
44
+ // High-value secrets → require Face ID / fingerprint / passcode to access.
45
+ // Opt in per use case; the OS shows the auth prompt on read.
46
+ const wallet = createStorage({ backend: 'secure', namespace: 'wallet', requireAuth: true });
47
+ await wallet.setString('privateKey', pk);
48
+ const pk = await wallet.getString('privateKey'); // ← triggers the biometric prompt
49
+
50
+ // Plain persistent data → SharedPreferences / UserDefaults
51
+ const prefs = createStorage({ backend: 'async', namespace: 'prefs' });
52
+ await prefs.setBoolean('onboarded', true);
53
+
54
+ // Ephemeral / tests → in-memory, zero native
55
+ const cache = createStorage({ backend: 'memory' });
56
+
57
+ // SYNCHRONOUS store (the MMKV-style use case) — load once, then sync everywhere.
58
+ import { createSyncStorage } from '@okint-digital/okint-rn-storage';
59
+ const fast = await createSyncStorage({ backend: 'fast', namespace: 'app' });
60
+ fast.setBoolean('onboarded', true); // sync write (persists in background)
61
+ const onboarded = fast.getBoolean('onboarded'); // sync read — no await
62
+ await fast.flush(); // optional: guarantee durability
63
+ ```
64
+
65
+ ### API
66
+
67
+ Every instance implements:
68
+
69
+ | Method | Notes |
70
+ |---|---|
71
+ | `getString / setString` | raw strings |
72
+ | `getItem<T> / setItem<T>` | JSON (throws `PARSE_ERROR` on malformed read) |
73
+ | `getNumber / setNumber` | numbers |
74
+ | `getBoolean / setBoolean` | booleans |
75
+ | `has(key)` | presence check |
76
+ | `remove(key)` · `clear()` · `keys()` | |
77
+ | `multiGet / multiSet / multiRemove` | batched string ops |
78
+ | `backend` | the backing `BackendKind` |
79
+
80
+ All methods return Promises and **reject** (never throw synchronously) on
81
+ invalid input. `namespace` partitions stores so they never collide.
82
+
83
+ ### Input validation
84
+
85
+ - **Namespace** becomes a file/service name *and* the suffix of a native SQLite
86
+ table (`kv_<ns>` / `enc_<ns>`) restricted to `[A-Za-z0-9_]` (1–200 chars).
87
+ `.`, `-`, `../`, `/`, spaces, etc. are rejected (`INVALID_NAMESPACE`). `.` and
88
+ `-` are deliberately **disallowed**: the native table builders only preserve
89
+ `[A-Za-z0-9_]` and collapse everything else to `_`, so allowing them would let
90
+ two distinct namespaces (`a.b`, `a-b`, `a_b`) map to the **same** table and
91
+ silently share / overwrite / wipe each other's data. Restricting to `_` makes
92
+ the JS→native mapping injective, so namespaces can never collide. Native code
93
+ re-validates this independently. *(Breaking vs ≤0.7.1, which accepted `.`/`-`.)*
94
+ - **Keys** must be non-empty strings without control characters (`INVALID_KEY`).
95
+ - **`setNumber`** rejects `NaN`/`±Infinity` (they don't round-trip). Numbers are
96
+ IEEE-754 doubles — for integers above 2^53 (e.g. snowflake IDs) use `setString`.
97
+ - **`setItem`** rejects non-JSON-serializable values (`undefined`, functions,
98
+ symbols, circular refs, BigInt) with `INVALID_VALUE` instead of corrupting.
99
+ - **`getBoolean`** is strict: only canonical `"true"`/`"false"` map; anything
100
+ else returns `null`.
101
+
102
+ ## Backends
103
+
104
+ | Kind | Android | iOS | Encrypted | Use for |
105
+ |---|---|---|---|---|
106
+ | `secure` | AES-256-GCM (Keystore key) over SharedPreferences | Keychain (`kSecClassGenericPassword`) | ✅ hardware | JWTs, refresh/FCM tokens, secrets |
107
+ | `async` | SharedPreferences | UserDefaults suite | | large / non-sensitive data |
108
+ | `memory` | (pure JS) | (pure JS) | n/a | ephemeral cache, tests |
109
+ | `fast` (sync) | SharedPreferences snapshot | UserDefaults snapshot | | **synchronous** state/flags/cache (MMKV-style) — via `createSyncStorage` |
110
+ | `encrypted` | AES-256-GCM keys **and** values (Keystore key) over SQLite | AES-256-CBC + HMAC-SHA256 keys **and** values (key in Keychain) over SQLite | Android: **hardware Keystore** key · iOS: **Keychain-stored software key** (not Secure Enclave) | large encrypted blobs / encrypted DB |
111
+ | `sqlite` | SQLite key/value table | SQLite (`sqlite3`) key/value table | ❌ | larger datasets, SQL-backed key/value |
112
+
113
+ All five backends are implemented. `encrypted` is a genuinely encrypted
114
+ database: **both keys and values** are sealed with an authenticated cipher, and
115
+ rows are looked up by a deterministic HMAC token so nothing readable touches
116
+ disk, yet it still scales to large blobs and many entries. The key is a
117
+ non-exportable **hardware Keystore** key on Android; on iOS it is a
118
+ random key held in the **Keychain** (OS-protected at rest, but *not*
119
+ Secure-Enclave-isolated see Security & reliability). `requireAuth` gates only
120
+ the `secure` backend, **not** `encrypted`.
121
+
122
+ > **Note on the deterministic token.** Lookups use `HMAC(key)` as the row id, so
123
+ > the database never stores a readable key but equal plaintext keys produce the
124
+ > same token. Anyone who can read the raw DB file can therefore tell *how many*
125
+ > entries exist and whether two snapshots share a key name (not the key/value
126
+ > itself). This is the standard cost of indexed encrypted lookup. No SQLCipher dependency.
127
+
128
+ ### Synchronous (`fast`) store
129
+
130
+ `createStorage` is async (correct for `secure` never block the UI thread on
131
+ Keystore crypto). For the MMKV-style **synchronous** need — persist/rehydrate,
132
+ feature flags, hot-path UI state — use `createSyncStorage`:
133
+
134
+ - It loads a snapshot **once**, then every `get`/`set` is **synchronous** in-JS
135
+ memory (the fastest possible read path no per-call bridge crossing).
136
+ - Writes apply to memory immediately and persist in the background, **coalesced**
137
+ per key; call `flush()` (e.g. on app background) for a durability barrier.
138
+ - **Zero-load variant** `createSyncStorageSync` hydrates the snapshot in a
139
+ single blocking native bulk-read and returns synchronously, so state is
140
+ available immediately at startup (e.g. before first render):
141
+
142
+ ```ts
143
+ import { createSyncStorageSync } from '@okint-digital/okint-rn-storage';
144
+ const fast = createSyncStorageSync({ backend: 'fast', namespace: 'app' });
145
+ const onboarded = fast.getBoolean('onboarded'); // sync, no await, no load step
146
+ ```
147
+
148
+ - **JSI engine** `createJSIStorage` installs a C++ `jsi::HostObject` and runs
149
+ every `get`/`set` **directly in C++ with no bridge serialization** the
150
+ maximum-performance synchronous path, with no JS-memory snapshot:
151
+
152
+ ```ts
153
+ import { createJSIStorage } from '@okint-digital/okint-rn-storage';
154
+ const kv = createJSIStorage({ namespace: 'app' });
155
+ kv.setString('theme', 'dark'); // sync, in C++
156
+ const theme = kv.getString('theme'); // sync, in C++
157
+ ```
158
+
159
+ It installs lazily on first use and throws a clear error under remote JS
160
+ debugging (no JSI runtime) — fall back to `createSyncStorageSync` there.
161
+
162
+ > **Opt-in native build.** The C++/JSI engine needs the NDK + CMake + the
163
+ > `ReactAndroid::jsi` prefab, so it's **off by default** the package builds
164
+ > as pure Kotlin/Java and every other store works without it. Enable it on
165
+ > **Android** with `-PokintEnableJSI=true` (or `okintEnableJSI=true` in
166
+ > `android/gradle.properties`); on **iOS** it's always compiled in. When
167
+ > disabled, `createJSIStorage` reports the engine unavailable use
168
+ > `createSyncStorageSync` (still synchronous, no NDK).
169
+
170
+ Use `secure` for tokens — never a sync store.
171
+
172
+ ## Compared to alternatives
173
+
174
+ | | okint-rn-storage | react-native-keychain | react-native-encrypted-storage | expo-secure-store | react-native-mmkv | async-storage |
175
+ |---|---|---|---|---|---|---|
176
+ | Secure (hardware-backed) | ✅ | ✅ | ✅ | ✅ | ❌ (key in JS) | ❌ |
177
+ | Plain persistent store | ✅ (`async`) | ❌ | ❌ | ❌ | ✅ | ✅ |
178
+ | Synchronous access | (`fast` snapshot · zero-load · **C++/JSI**) | ❌ | ❌ | ❌ | ✅ (mmap) | ❌ |
179
+ | In-memory / test backend | | | ❌ | ❌ | ❌ | ❌ |
180
+ | One API, swappable backends | | | | ❌ | ❌ | ❌ |
181
+ | New Architecture (RN 0.81) | (interop) | (TurboModule) | ❌ unmaintained | ✅ | ✅ (v3 requires it) | ✅ |
182
+ | Android crash-recovery¹ | ✅ | partial | ❌ | n/a | n/a | n/a |
183
+ | Third-party runtime deps | none | none | none | Expo modules | MMKV (C++) | — |
184
+ | Maintained (2026) | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
185
+
186
+ ¹ Encrypted Android stores can break after backup/restore, device transfer, or
187
+ Keystore key invalidation historically a **startup crash** (the gap that sank
188
+ `react-native-encrypted-storage`, which wrapped EncryptedSharedPreferences). okint
189
+ never crashes on this: a value that can't be decrypted with the current Keystore
190
+ key simply reads back as `null`, so the app re-authenticates instead of dying on
191
+ launch.
192
+
193
+ **When to use what:** secrets/tokens → `secure` (async, hardware-backed). Big or
194
+ non-sensitive data → `async`. Synchronous state/flags/cache → `fast` (via
195
+ `createSyncStorage`) this is okint's MMKV replacement, so you don't need a
196
+ separate sync library. Tests/ephemeral → `memory`. One package, every store.
197
+
198
+ ## Errors
199
+
200
+ All failures throw `OkintStorageError` with a stable `code`:
201
+ `NATIVE_MODULE_MISSING` · `BACKEND_NOT_IMPLEMENTED` · `UNKNOWN_BACKEND` ·
202
+ `PARSE_ERROR` · `INVALID_VALUE` · `NATIVE_ERROR`.
203
+
204
+ ```ts
205
+ import { OkintStorageError } from '@okint-digital/okint-rn-storage';
206
+ try { await auth.getItem('x'); }
207
+ catch (e) { if (e instanceof OkintStorageError && e.code === 'PARSE_ERROR') { /* */ } }
208
+ ```
209
+
210
+ ## Security & reliability
211
+
212
+ - **Android `secure`** encrypts every value with **AES-256-GCM** under a
213
+ per-namespace, non-exportable **AndroidKeystore** key, preferring the dedicated
214
+ **StrongBox** secure element (Titan M / SE) and falling back to the TEE; ciphertext
215
+ is held in plain SharedPreferences. This is the same construction
216
+ `EncryptedSharedPreferences` used internally without the now-deprecated
217
+ `androidx.security:security-crypto`, and with **no third-party dependency** (Tink,
218
+ DataStore, etc.). A failed decrypt (restored backup, invalidated key) returns
219
+ `null` rather than crashing on launch.
220
+ - **Biometric / device-credential gating (`requireAuth`)** opt-in per secure
221
+ store. iOS binds the Keychain item to the **Secure Enclave** via `SecAccessControl`
222
+ (`.userPresence` Face ID / Touch ID *or* passcode); the OS prompts automatically
223
+ on read. Android (API 28+) marks the AES key `setUserAuthenticationRequired` and
224
+ routes every read/write through a framework **`BiometricPrompt`** bound to the
225
+ operation's `Cipher` (strong biometric; per-operation). With no enrolled
226
+ authenticator, or on API < 28, gated calls reject rather than silently
227
+ downgrading. Off by default nothing prompts unless you ask for it.
228
+ - **iOS `secure`** uses the Keychain with
229
+ `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (not iCloud-synced, not in
230
+ encrypted backups, available to background tasks after first unlock) + the
231
+ data-protection keychain. Writes are add-or-update (`SecItemUpdate`
232
+ `SecItemAdd`). The module is Objective-C for maximum build compatibility (no
233
+ Swift / `use_frameworks!` pitfalls).
234
+ - **`encrypted`** authenticates as well as encrypts, and seals **both keys and
235
+ values**: Android AES-256-GCM (per-namespace Keystore key); iOS AES-256-CBC +
236
+ HMAC-SHA256 encrypt-then-MAC (96-byte key in the Keychain, constant-time MAC
237
+ check). Rows are addressed by a deterministic HMAC token, so the database holds
238
+ no readable key or value, yet scales to large blobs and many entries.
239
+ - Keychain/Keystore are sized for secrets, not megabytes. Store tokens & keys in
240
+ `secure`; store bulk data in `async`, or encrypted bulk data in `encrypted`.
241
+ - **Secrets are never logged** (avoids the class of bug behind CVE-2024-21668 in
242
+ another RN storage lib). Error messages carry key names + OS status codes only.
243
+ - **A failed decrypt returns `null`, never a crash** — by design (crash-recovery).
244
+ For `secure`/`encrypted` this means `null` can signify *either* "no value" *or*
245
+ "the stored ciphertext could not be authenticated" (lost/rotated Keystore key,
246
+ or tampering). Treat a `null` where you expected a value as "re-authenticate",
247
+ not "definitely never stored".
248
+ - **`requireAuth` reads (iOS):** a user-cancelled or failed biometric **rejects**
249
+ (`E_OKINT_AUTH` / `E_OKINT_AUTH_CANCELLED`) rather than resolving `null`, so a
250
+ declined prompt is never mistaken for "logged out" — matching Android.
251
+ - **Backups.** The iOS plaintext SQLite DB is excluded from iCloud/iTunes backup
252
+ in-code; the iOS `secure` Keychain uses `…ThisDeviceOnly` (not backed up). On
253
+ **Android**, `secure`/`encrypted` ciphertext lives in app-private storage that
254
+ the host app's default `allowBackup=true` will copy off-device — the Keystore
255
+ key never leaves the device, so backed-up ciphertext is **non-decryptable**
256
+ (data is lost on restore rather than exposed). If you want it excluded, add a
257
+ backup rule in your app (`android:dataExtractionRules` / `fullBackupContent`)
258
+ excluding `okint_secure_*` shared-prefs and `okint_sqlite.db`.
259
+ - **`fast` (snapshot) and `createJSIStorage` are separate physical stores** — the
260
+ JSI engine persists to its own `okint_jsi_<ns>.bin`, not the `async` store the
261
+ `fast` snapshot uses. They do **not** share data; don't treat one as a drop-in
262
+ fallback for the other's data.
263
+
264
+ ### Threat model (read this)
265
+
266
+ Hardware-backed Keystore/Keychain protects secrets **at rest on an uncompromised
267
+ device**. It does **not** protect against: rooted/jailbroken devices, runtime
268
+ instrumentation (Frida) or memory dumps of a running app, malware running as the
269
+ same app, or a handed-over unlocked device. For high-value secrets, pair okint
270
+ with root/jailbreak detection and short-lived tokens. okint encrypts on Android
271
+ by **default** (unlike libraries that fall back to plaintext SharedPreferences).
272
+
273
+ ## License
274
+
275
+ MIT © Okint Digital — see [LICENSE](./LICENSE).
@@ -32,6 +32,10 @@ android {
32
32
  externalNativeBuild {
33
33
  cmake {
34
34
  cppFlags "-O2", "-frtti", "-fexceptions", "-std=c++17"
35
+ // React Native's prefab libs (jsi / hermestooling) are built against
36
+ // the shared STL; the consuming CMake build MUST match or prefab
37
+ // resolution fails ("static STL but library requires a shared STL").
38
+ arguments "-DANDROID_STL=c++_shared"
35
39
  }
36
40
  }
37
41
  }