@okint-digital/okint-rn-storage 0.7.1 → 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/README.md +275 -240
- package/android/build.gradle +4 -0
- package/android/src/main/java/com/okint/rnstorage/OkintRnStorageModule.kt +560 -520
- package/cpp/OkintJSI.cpp +325 -170
- package/ios/OkintRnStorage.m +604 -501
- package/ios/OkintRnStorageJSI.mm +32 -24
- package/lib/facade.js +5 -1
- package/lib/index.js +6 -1
- package/lib/sync/jsi-store.js +2 -1
- package/lib/sync/sync-store.d.ts +19 -2
- package/lib/sync/sync-store.js +97 -13
- package/lib/validate.d.ts +1 -1
- package/lib/validate.js +29 -7
- package/package.json +74 -74
- package/src/facade.ts +122 -118
- package/src/index.ts +200 -196
- package/src/sync/jsi-store.ts +99 -98
- package/src/sync/sync-store.ts +266 -186
- package/src/validate.ts +124 -102
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
|
|
86
|
-
(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
- **
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
`
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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).
|
package/android/build.gradle
CHANGED
|
@@ -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
|
}
|