@okint-digital/okint-rn-storage 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/android/build.gradle +59 -0
  4. package/android/proguard-rules.pro +8 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/okint/rnstorage/OkintRnStorageModule.kt +354 -0
  7. package/android/src/main/java/com/okint/rnstorage/OkintRnStoragePackage.kt +14 -0
  8. package/android/src/main/jni/CMakeLists.txt +24 -0
  9. package/android/src/main/jni/OkintJNI.cpp +15 -0
  10. package/cpp/OkintJSI.cpp +170 -0
  11. package/cpp/OkintJSI.h +18 -0
  12. package/ios/OkintRnStorage.m +457 -0
  13. package/ios/OkintRnStorageJSI.mm +24 -0
  14. package/lib/backends/memory.d.ts +16 -0
  15. package/lib/backends/memory.js +31 -0
  16. package/lib/backends/native-backend.d.ts +24 -0
  17. package/lib/backends/native-backend.js +73 -0
  18. package/lib/errors.d.ts +9 -0
  19. package/lib/errors.js +19 -0
  20. package/lib/facade.d.ts +29 -0
  21. package/lib/facade.js +93 -0
  22. package/lib/index.d.ts +48 -0
  23. package/lib/index.js +172 -0
  24. package/lib/native/bridge.d.ts +12 -0
  25. package/lib/native/bridge.js +23 -0
  26. package/lib/native/jsi.d.ts +7 -0
  27. package/lib/native/jsi.js +33 -0
  28. package/lib/sync/jsi-store.d.ts +28 -0
  29. package/lib/sync/jsi-store.js +81 -0
  30. package/lib/sync/persistence.d.ts +19 -0
  31. package/lib/sync/persistence.js +49 -0
  32. package/lib/sync/sync-store.d.ts +49 -0
  33. package/lib/sync/sync-store.js +159 -0
  34. package/lib/types.d.ts +140 -0
  35. package/lib/types.js +10 -0
  36. package/lib/validate.d.ts +20 -0
  37. package/lib/validate.js +91 -0
  38. package/okint-rn-storage.podspec +27 -0
  39. package/package.json +74 -0
  40. package/react-native.config.js +15 -0
  41. package/src/backends/memory.ts +35 -0
  42. package/src/backends/native-backend.ts +69 -0
  43. package/src/errors.ts +26 -0
  44. package/src/facade.ts +118 -0
  45. package/src/index.ts +194 -0
  46. package/src/native/bridge.ts +28 -0
  47. package/src/native/jsi.ts +37 -0
  48. package/src/sync/jsi-store.ts +98 -0
  49. package/src/sync/persistence.ts +47 -0
  50. package/src/sync/sync-store.ts +186 -0
  51. package/src/types.ts +174 -0
  52. package/src/validate.ts +102 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Okint Digital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,217 @@
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
+ // Plain persistent data → SharedPreferences / UserDefaults
45
+ const prefs = createStorage({ backend: 'async', namespace: 'prefs' });
46
+ await prefs.setBoolean('onboarded', true);
47
+
48
+ // Ephemeral / tests → in-memory, zero native
49
+ const cache = createStorage({ backend: 'memory' });
50
+
51
+ // SYNCHRONOUS store (the MMKV-style use case) — load once, then sync everywhere.
52
+ import { createSyncStorage } from '@okint-digital/okint-rn-storage';
53
+ const fast = await createSyncStorage({ backend: 'fast', namespace: 'app' });
54
+ fast.setBoolean('onboarded', true); // sync write (persists in background)
55
+ const onboarded = fast.getBoolean('onboarded'); // sync read — no await
56
+ await fast.flush(); // optional: guarantee durability
57
+ ```
58
+
59
+ ### API
60
+
61
+ Every instance implements:
62
+
63
+ | Method | Notes |
64
+ |---|---|
65
+ | `getString / setString` | raw strings |
66
+ | `getItem<T> / setItem<T>` | JSON (throws `PARSE_ERROR` on malformed read) |
67
+ | `getNumber / setNumber` | numbers |
68
+ | `getBoolean / setBoolean` | booleans |
69
+ | `has(key)` | presence check |
70
+ | `remove(key)` · `clear()` · `keys()` | |
71
+ | `multiGet / multiSet / multiRemove` | batched string ops |
72
+ | `backend` | the backing `BackendKind` |
73
+
74
+ All methods return Promises and **reject** (never throw synchronously) on
75
+ invalid input. `namespace` partitions stores so they never collide.
76
+
77
+ ### Input validation
78
+
79
+ - **Namespace** becomes a file/service name → restricted to `[A-Za-z0-9._-]`
80
+ (1–200 chars); `../`, `/`, spaces, etc. are rejected (`INVALID_NAMESPACE`) to
81
+ prevent filename injection / cross-store collisions.
82
+ - **Keys** must be non-empty strings without control characters (`INVALID_KEY`).
83
+ - **`setNumber`** rejects `NaN`/`±Infinity` (they don't round-trip). Numbers are
84
+ IEEE-754 doubles — for integers above 2^53 (e.g. snowflake IDs) use `setString`.
85
+ - **`setItem`** rejects non-JSON-serializable values (`undefined`, functions,
86
+ symbols, circular refs, BigInt) with `INVALID_VALUE` instead of corrupting.
87
+ - **`getBoolean`** is strict: only canonical `"true"`/`"false"` map; anything
88
+ else returns `null`.
89
+
90
+ ## Backends
91
+
92
+ | Kind | Android | iOS | Encrypted | Use for |
93
+ |---|---|---|---|---|
94
+ | `secure` | AES-256-GCM (Keystore key) over SharedPreferences | Keychain (`kSecClassGenericPassword`) | ✅ hardware | JWTs, refresh/FCM tokens, secrets |
95
+ | `async` | SharedPreferences | UserDefaults suite | ❌ | large / non-sensitive data |
96
+ | `memory` | — (pure JS) | — (pure JS) | n/a | ephemeral cache, tests |
97
+ | `fast` (sync) | SharedPreferences snapshot | UserDefaults snapshot | ❌ | **synchronous** state/flags/cache (MMKV-style) — via `createSyncStorage` |
98
+ | `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 |
99
+ | `sqlite` | SQLite key/value table | SQLite (`sqlite3`) key/value table | ❌ | larger datasets, SQL-backed key/value |
100
+
101
+ All five backends are implemented. `encrypted` is a genuinely encrypted
102
+ database: **both keys and values** are sealed with an authenticated cipher whose
103
+ key is rooted in the hardware Keystore/Keychain, and rows are looked up by a
104
+ deterministic HMAC token — so nothing readable touches disk, yet it still scales
105
+ to large blobs and many entries. No SQLCipher dependency.
106
+
107
+ ### Synchronous (`fast`) store
108
+
109
+ `createStorage` is async (correct for `secure` — never block the UI thread on
110
+ Keystore crypto). For the MMKV-style **synchronous** need — persist/rehydrate,
111
+ feature flags, hot-path UI state — use `createSyncStorage`:
112
+
113
+ - It loads a snapshot **once**, then every `get`/`set` is **synchronous** in-JS
114
+ memory (the fastest possible read path — no per-call bridge crossing).
115
+ - Writes apply to memory immediately and persist in the background, **coalesced**
116
+ per key; call `flush()` (e.g. on app background) for a durability barrier.
117
+ - **Zero-load variant** — `createSyncStorageSync` hydrates the snapshot in a
118
+ single blocking native bulk-read and returns synchronously, so state is
119
+ available immediately at startup (e.g. before first render):
120
+
121
+ ```ts
122
+ import { createSyncStorageSync } from '@okint-digital/okint-rn-storage';
123
+ const fast = createSyncStorageSync({ backend: 'fast', namespace: 'app' });
124
+ const onboarded = fast.getBoolean('onboarded'); // sync, no await, no load step
125
+ ```
126
+
127
+ - **JSI engine** — `createJSIStorage` installs a C++ `jsi::HostObject` and runs
128
+ every `get`/`set` **directly in C++ with no bridge serialization** — the
129
+ maximum-performance synchronous path, with no JS-memory snapshot:
130
+
131
+ ```ts
132
+ import { createJSIStorage } from '@okint-digital/okint-rn-storage';
133
+ const kv = createJSIStorage({ namespace: 'app' });
134
+ kv.setString('theme', 'dark'); // sync, in C++
135
+ const theme = kv.getString('theme'); // sync, in C++
136
+ ```
137
+
138
+ It installs lazily on first use and throws a clear error under remote JS
139
+ debugging (no JSI runtime) — fall back to `createSyncStorageSync` there.
140
+
141
+ Use `secure` for tokens — never a sync store.
142
+
143
+ ## Compared to alternatives
144
+
145
+ | | okint-rn-storage | react-native-keychain | react-native-encrypted-storage | expo-secure-store | react-native-mmkv | async-storage |
146
+ |---|---|---|---|---|---|---|
147
+ | Secure (hardware-backed) | ✅ | ✅ | ✅ | ✅ | ❌ (key in JS) | ❌ |
148
+ | Plain persistent store | ✅ (`async`) | ❌ | ❌ | ❌ | ✅ | ✅ |
149
+ | Synchronous access | ✅ (`fast` snapshot · zero-load · **C++/JSI**) | ❌ | ❌ | ❌ | ✅ (mmap) | ❌ |
150
+ | In-memory / test backend | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
151
+ | One API, swappable backends | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
152
+ | New Architecture (RN 0.81) | ✅ (interop) | ✅ (TurboModule) | ❌ unmaintained | ✅ | ✅ (v3 requires it) | ✅ |
153
+ | Android crash-recovery¹ | ✅ | partial | ❌ | n/a | n/a | n/a |
154
+ | Third-party runtime deps | none | none | none | Expo modules | MMKV (C++) | — |
155
+ | Maintained (2026) | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
156
+
157
+ ¹ Encrypted Android stores can break after backup/restore, device transfer, or
158
+ Keystore key invalidation — historically a **startup crash** (the gap that sank
159
+ `react-native-encrypted-storage`, which wrapped EncryptedSharedPreferences). okint
160
+ never crashes on this: a value that can't be decrypted with the current Keystore
161
+ key simply reads back as `null`, so the app re-authenticates instead of dying on
162
+ launch.
163
+
164
+ **When to use what:** secrets/tokens → `secure` (async, hardware-backed). Big or
165
+ non-sensitive data → `async`. Synchronous state/flags/cache → `fast` (via
166
+ `createSyncStorage`) — this is okint's MMKV replacement, so you don't need a
167
+ separate sync library. Tests/ephemeral → `memory`. One package, every store.
168
+
169
+ ## Errors
170
+
171
+ All failures throw `OkintStorageError` with a stable `code`:
172
+ `NATIVE_MODULE_MISSING` · `BACKEND_NOT_IMPLEMENTED` · `UNKNOWN_BACKEND` ·
173
+ `PARSE_ERROR` · `INVALID_VALUE` · `NATIVE_ERROR`.
174
+
175
+ ```ts
176
+ import { OkintStorageError } from '@okint-digital/okint-rn-storage';
177
+ try { await auth.getItem('x'); }
178
+ catch (e) { if (e instanceof OkintStorageError && e.code === 'PARSE_ERROR') { /* … */ } }
179
+ ```
180
+
181
+ ## Security & reliability
182
+
183
+ - **Android `secure`** encrypts every value with **AES-256-GCM** under a
184
+ per-namespace, non-exportable **AndroidKeystore** key (hardware-backed where the
185
+ device offers it); ciphertext is held in plain SharedPreferences. This is the
186
+ same construction `EncryptedSharedPreferences` used internally — without the
187
+ now-deprecated `androidx.security:security-crypto`, and with **no third-party
188
+ dependency** (Tink, DataStore, etc.). A failed decrypt (restored backup,
189
+ invalidated key) returns `null` rather than crashing on launch.
190
+ - **iOS `secure`** uses the Keychain with
191
+ `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (not iCloud-synced, not in
192
+ encrypted backups, available to background tasks after first unlock) + the
193
+ data-protection keychain. Writes are add-or-update (`SecItemUpdate` →
194
+ `SecItemAdd`). The module is Objective-C for maximum build compatibility (no
195
+ Swift / `use_frameworks!` pitfalls).
196
+ - **`encrypted`** authenticates as well as encrypts, and seals **both keys and
197
+ values**: Android AES-256-GCM (per-namespace Keystore key); iOS AES-256-CBC +
198
+ HMAC-SHA256 encrypt-then-MAC (96-byte key in the Keychain, constant-time MAC
199
+ check). Rows are addressed by a deterministic HMAC token, so the database holds
200
+ no readable key or value, yet scales to large blobs and many entries.
201
+ - Keychain/Keystore are sized for secrets, not megabytes. Store tokens & keys in
202
+ `secure`; store bulk data in `async`, or encrypted bulk data in `encrypted`.
203
+ - **Secrets are never logged** (avoids the class of bug behind CVE-2024-21668 in
204
+ another RN storage lib). Error messages carry key names + OS status codes only.
205
+
206
+ ### Threat model (read this)
207
+
208
+ Hardware-backed Keystore/Keychain protects secrets **at rest on an uncompromised
209
+ device**. It does **not** protect against: rooted/jailbroken devices, runtime
210
+ instrumentation (Frida) or memory dumps of a running app, malware running as the
211
+ same app, or a handed-over unlocked device. For high-value secrets, pair okint
212
+ with root/jailbreak detection and short-lived tokens. okint encrypts on Android
213
+ by **default** (unlike libraries that fall back to plaintext SharedPreferences).
214
+
215
+ ## License
216
+
217
+ MIT © Okint Digital — see [LICENSE](./LICENSE).
@@ -0,0 +1,59 @@
1
+ // okint-rn-storage — Android library build.
2
+ // Relies on the host app's React Native Gradle setup for the Android Gradle
3
+ // Plugin + Kotlin plugin classpath (RN 0.73+ convention), so no buildscript
4
+ // block is needed here. Versions fall back to sensible defaults if the root
5
+ // project doesn't expose them.
6
+
7
+ apply plugin: "com.android.library"
8
+ apply plugin: "org.jetbrains.kotlin.android"
9
+
10
+ def safeExtGet(prop, fallback) {
11
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
12
+ }
13
+
14
+ android {
15
+ namespace "com.okint.rnstorage"
16
+ compileSdkVersion safeExtGet("compileSdkVersion", 35)
17
+
18
+ defaultConfig {
19
+ minSdkVersion safeExtGet("minSdkVersion", 24)
20
+ targetSdkVersion safeExtGet("targetSdkVersion", 35)
21
+ consumerProguardFiles "proguard-rules.pro"
22
+ externalNativeBuild {
23
+ cmake {
24
+ cppFlags "-O2", "-frtti", "-fexceptions", "-std=c++17"
25
+ }
26
+ }
27
+ }
28
+
29
+ // Consume React Native's prefab (JSI) for the C++ fast-path engine.
30
+ buildFeatures {
31
+ prefab true
32
+ }
33
+
34
+ externalNativeBuild {
35
+ cmake {
36
+ path "src/main/jni/CMakeLists.txt"
37
+ }
38
+ }
39
+
40
+ compileOptions {
41
+ sourceCompatibility JavaVersion.VERSION_17
42
+ targetCompatibility JavaVersion.VERSION_17
43
+ }
44
+
45
+ kotlinOptions {
46
+ jvmTarget = "17"
47
+ }
48
+ }
49
+
50
+ repositories {
51
+ mavenCentral()
52
+ google()
53
+ }
54
+
55
+ dependencies {
56
+ // Version supplied by the host app's React Native Gradle Plugin — do NOT pin.
57
+ // No other dependencies: all crypto is platform javax.crypto + AndroidKeystore.
58
+ implementation "com.facebook.react:react-android"
59
+ }
@@ -0,0 +1,8 @@
1
+ # okint-rn-storage — consumer ProGuard/R8 rules.
2
+ # Keep the module + its JNI entry point so autolinking and the native
3
+ # System.loadLibrary("okint") / nativeInstallJSI binding survive minification.
4
+
5
+ -keep class com.okint.rnstorage.** { *; }
6
+ -keepclasseswithmembernames class com.okint.rnstorage.** {
7
+ native <methods>;
8
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,354 @@
1
+ package com.okint.rnstorage
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.database.sqlite.SQLiteDatabase
6
+ import android.database.sqlite.SQLiteOpenHelper
7
+ import android.security.keystore.KeyGenParameterSpec
8
+ import android.security.keystore.KeyProperties
9
+ import android.util.Base64
10
+ import com.facebook.react.bridge.Arguments
11
+ import com.facebook.react.bridge.Promise
12
+ import com.facebook.react.bridge.ReactApplicationContext
13
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
14
+ import com.facebook.react.bridge.ReactMethod
15
+ import com.facebook.react.bridge.WritableMap
16
+ import java.security.KeyStore
17
+ import java.util.concurrent.ConcurrentHashMap
18
+ import javax.crypto.Cipher
19
+ import javax.crypto.KeyGenerator
20
+ import javax.crypto.Mac
21
+ import javax.crypto.SecretKey
22
+ import javax.crypto.spec.GCMParameterSpec
23
+
24
+ /**
25
+ * okint-rn-storage — Android native module. One module, four stores, ZERO
26
+ * third-party dependencies (only the Android platform + AndroidKeystore):
27
+ *
28
+ * - "secure" → AES-256-GCM with a per-namespace, non-exportable
29
+ * AndroidKeystore key (hardware-backed where available);
30
+ * ciphertext lives in plain SharedPreferences. This is the
31
+ * construction EncryptedSharedPreferences used internally,
32
+ * without the deprecated androidx.security dependency. A
33
+ * decrypt failure (restored backup, invalidated key) returns
34
+ * null instead of crashing at launch — crash-recovery built in.
35
+ * - "async" → plain SharedPreferences (fast, unencrypted).
36
+ * - "encrypted" → a fully-encrypted SQLite table: both KEYS and VALUES are
37
+ * AES-256-GCM encrypted; lookups use a deterministic HMAC
38
+ * token (Keystore HMAC key). No plaintext in the database — an
39
+ * encrypted DB with no SQLCipher dependency, sized for large
40
+ * blobs / many entries.
41
+ * - "sqlite" → plaintext values in a separate SQLite table.
42
+ *
43
+ * Plus a blocking-sync bulk read (`getEntriesSync`) and a C++/JSI fast-path
44
+ * installer (`installJSI`).
45
+ *
46
+ * NOTE: native code is written against the stable Android crypto/Keystore APIs
47
+ * and verified at app build time (no Gradle/NDK in the authoring environment).
48
+ */
49
+ class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
50
+ ReactContextBaseJavaModule(reactContext) {
51
+
52
+ override fun getName(): String = NAME
53
+
54
+ // ── Dispatch ────────────────────────────────────────────────────────────────
55
+
56
+ @ReactMethod
57
+ fun setItem(service: String, key: String, value: String, store: String, promise: Promise) {
58
+ try {
59
+ when (store) {
60
+ STORE_SECURE -> securePrefs(service).edit().putString(key, encrypt(service, value)).commit()
61
+ STORE_ENCRYPTED -> encSet(service, key, value)
62
+ STORE_SQLITE -> sqliteSet(service, key, value)
63
+ else -> asyncPrefs(service).edit().putString(key, value).commit()
64
+ }
65
+ promise.resolve(null)
66
+ } catch (e: Exception) {
67
+ promise.reject("E_OKINT_SET", e.message, e)
68
+ }
69
+ }
70
+
71
+ @ReactMethod
72
+ fun getItem(service: String, key: String, store: String, promise: Promise) {
73
+ try {
74
+ promise.resolve(readOne(service, key, store))
75
+ } catch (e: Exception) {
76
+ promise.reject("E_OKINT_GET", e.message, e)
77
+ }
78
+ }
79
+
80
+ @ReactMethod
81
+ fun removeItem(service: String, key: String, store: String, promise: Promise) {
82
+ try {
83
+ when (store) {
84
+ STORE_SECURE -> securePrefs(service).edit().remove(key).commit()
85
+ STORE_ENCRYPTED -> encExec(service, "DELETE FROM ${encTable(service)} WHERE kt=?", arrayOf(token(service, key)))
86
+ STORE_SQLITE -> sqliteExec(service, "DELETE FROM ${kvTable(service)} WHERE k=?", arrayOf(key))
87
+ else -> asyncPrefs(service).edit().remove(key).commit()
88
+ }
89
+ promise.resolve(null)
90
+ } catch (e: Exception) {
91
+ promise.reject("E_OKINT_REMOVE", e.message, e)
92
+ }
93
+ }
94
+
95
+ @ReactMethod
96
+ fun clear(service: String, store: String, promise: Promise) {
97
+ try {
98
+ when (store) {
99
+ STORE_SECURE -> securePrefs(service).edit().clear().commit()
100
+ STORE_ENCRYPTED -> encExec(service, "DELETE FROM ${encTable(service)}", emptyArray())
101
+ STORE_SQLITE -> sqliteExec(service, "DELETE FROM ${kvTable(service)}", emptyArray())
102
+ else -> asyncPrefs(service).edit().clear().commit()
103
+ }
104
+ promise.resolve(null)
105
+ } catch (e: Exception) {
106
+ promise.reject("E_OKINT_CLEAR", e.message, e)
107
+ }
108
+ }
109
+
110
+ @ReactMethod
111
+ fun getAllKeys(service: String, store: String, promise: Promise) {
112
+ try {
113
+ val arr = Arguments.createArray()
114
+ for (k in allKeys(service, store)) arr.pushString(k)
115
+ promise.resolve(arr)
116
+ } catch (e: Exception) {
117
+ promise.reject("E_OKINT_KEYS", e.message, e)
118
+ }
119
+ }
120
+
121
+ /** Blocking-synchronous bulk read for the zero-load sync store. */
122
+ @ReactMethod(isBlockingSynchronousMethod = true)
123
+ fun getEntriesSync(service: String, store: String): WritableMap {
124
+ val map = Arguments.createMap()
125
+ try {
126
+ when (store) {
127
+ STORE_ASYNC -> for ((k, v) in asyncPrefs(service).all) {
128
+ if (v is String) map.putString(k, v)
129
+ }
130
+ else -> for (k in allKeys(service, store)) {
131
+ readOne(service, k, store)?.let { map.putString(k, it) }
132
+ }
133
+ }
134
+ } catch (ignored: Exception) {
135
+ }
136
+ return map
137
+ }
138
+
139
+ /** Install the C++/JSI fast-path engine. Returns false if the runtime is unreachable. */
140
+ @ReactMethod(isBlockingSynchronousMethod = true)
141
+ fun installJSI(): Boolean {
142
+ return try {
143
+ val ptr = reactContext.javaScriptContextHolder?.get() ?: return false
144
+ if (ptr == 0L) return false
145
+ nativeInstallJSI(ptr, reactContext.filesDir.absolutePath)
146
+ true
147
+ } catch (e: Throwable) {
148
+ false
149
+ }
150
+ }
151
+
152
+ private external fun nativeInstallJSI(jsiPtr: Long, dir: String)
153
+
154
+ // ── Shared read helpers ──────────────────────────────────────────────────────
155
+
156
+ private fun readOne(service: String, key: String, store: String): String? = when (store) {
157
+ STORE_SECURE -> securePrefs(service).getString(key, null)?.let { decryptOrNull(service, it) }
158
+ STORE_ENCRYPTED -> encGet(service, key)
159
+ STORE_SQLITE -> sqliteGet(service, key)
160
+ else -> asyncPrefs(service).getString(key, null)
161
+ }
162
+
163
+ private fun allKeys(service: String, store: String): List<String> = when (store) {
164
+ STORE_SECURE -> securePrefs(service).all.keys.toList()
165
+ STORE_ENCRYPTED -> encKeys(service)
166
+ STORE_SQLITE -> sqliteKeys(service)
167
+ else -> asyncPrefs(service).all.keys.toList()
168
+ }
169
+
170
+ // ── SharedPreferences (secure ciphertext + async plaintext) ──────────────────
171
+
172
+ private val prefsCache = ConcurrentHashMap<String, SharedPreferences>()
173
+
174
+ private fun prefs(name: String): SharedPreferences =
175
+ prefsCache.getOrPut(name) { reactContext.getSharedPreferences(name, Context.MODE_PRIVATE) }
176
+
177
+ private fun asyncPrefs(service: String): SharedPreferences = prefs("okint_$service")
178
+
179
+ private fun securePrefs(service: String): SharedPreferences = prefs("okint_secure_$service")
180
+
181
+ // ── Crypto core (AES-256-GCM + HMAC token, rooted in AndroidKeystore) ────────
182
+
183
+ private fun aesKey(service: String): SecretKey {
184
+ val alias = "okint_enckey_$service"
185
+ val ks = KeyStore.getInstance(ANDROID_KEYSTORE)
186
+ ks.load(null)
187
+ (ks.getKey(alias, null) as? SecretKey)?.let { return it }
188
+ val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
189
+ kg.init(
190
+ KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
191
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
192
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
193
+ .setKeySize(256)
194
+ .build(),
195
+ )
196
+ return kg.generateKey()
197
+ }
198
+
199
+ private fun hmacKey(service: String): SecretKey {
200
+ val alias = "okint_enchmac_$service"
201
+ val ks = KeyStore.getInstance(ANDROID_KEYSTORE)
202
+ ks.load(null)
203
+ (ks.getKey(alias, null) as? SecretKey)?.let { return it }
204
+ val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE)
205
+ kg.init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN).build())
206
+ return kg.generateKey()
207
+ }
208
+
209
+ private fun token(service: String, key: String): String {
210
+ val mac = Mac.getInstance("HmacSHA256")
211
+ mac.init(hmacKey(service))
212
+ return Base64.encodeToString(mac.doFinal(key.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
213
+ }
214
+
215
+ private fun encrypt(service: String, plaintext: String): String {
216
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
217
+ cipher.init(Cipher.ENCRYPT_MODE, aesKey(service))
218
+ val iv = cipher.iv
219
+ val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
220
+ val out = ByteArray(1 + iv.size + ct.size)
221
+ out[0] = iv.size.toByte()
222
+ System.arraycopy(iv, 0, out, 1, iv.size)
223
+ System.arraycopy(ct, 0, out, 1 + iv.size, ct.size)
224
+ return Base64.encodeToString(out, Base64.NO_WRAP)
225
+ }
226
+
227
+ private fun decrypt(service: String, b64: String): String {
228
+ val data = Base64.decode(b64, Base64.NO_WRAP)
229
+ val ivLen = data[0].toInt() and 0xFF
230
+ val iv = data.copyOfRange(1, 1 + ivLen)
231
+ val ct = data.copyOfRange(1 + ivLen, data.size)
232
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
233
+ cipher.init(Cipher.DECRYPT_MODE, aesKey(service), GCMParameterSpec(128, iv))
234
+ return String(cipher.doFinal(ct), Charsets.UTF_8)
235
+ }
236
+
237
+ /** Decrypt that tolerates a lost/rotated key (restored backup) — returns null, never crashes. */
238
+ private fun decryptOrNull(service: String, b64: String): String? = try {
239
+ decrypt(service, b64)
240
+ } catch (e: Exception) {
241
+ null
242
+ }
243
+
244
+ // ── encrypted store (enc_ table: HMAC token + encrypted key + encrypted value)
245
+
246
+ private fun encTable(service: String): String = "enc_" + service.replace(Regex("[^A-Za-z0-9_]"), "_")
247
+
248
+ private fun ensureEnc(db: SQLiteDatabase, service: String) {
249
+ db.execSQL("CREATE TABLE IF NOT EXISTS ${encTable(service)} (kt TEXT PRIMARY KEY, ke TEXT NOT NULL, ve TEXT NOT NULL)")
250
+ }
251
+
252
+ @Synchronized
253
+ private fun encSet(service: String, key: String, value: String) {
254
+ val db = dbHelper.writableDatabase
255
+ ensureEnc(db, service)
256
+ db.execSQL(
257
+ "INSERT OR REPLACE INTO ${encTable(service)} (kt, ke, ve) VALUES (?, ?, ?)",
258
+ arrayOf(token(service, key), encrypt(service, key), encrypt(service, value)),
259
+ )
260
+ }
261
+
262
+ @Synchronized
263
+ private fun encGet(service: String, key: String): String? {
264
+ val db = dbHelper.readableDatabase
265
+ ensureEnc(db, service)
266
+ db.rawQuery("SELECT ve FROM ${encTable(service)} WHERE kt = ?", arrayOf(token(service, key))).use { c ->
267
+ return if (c.moveToFirst()) decryptOrNull(service, c.getString(0)) else null
268
+ }
269
+ }
270
+
271
+ @Synchronized
272
+ private fun encExec(service: String, sql: String, args: Array<String>) {
273
+ val db = dbHelper.writableDatabase
274
+ ensureEnc(db, service)
275
+ if (args.isEmpty()) db.execSQL(sql) else db.execSQL(sql, args)
276
+ }
277
+
278
+ @Synchronized
279
+ private fun encKeys(service: String): List<String> {
280
+ val db = dbHelper.readableDatabase
281
+ ensureEnc(db, service)
282
+ val keys = ArrayList<String>()
283
+ db.rawQuery("SELECT ke FROM ${encTable(service)}", emptyArray()).use { c ->
284
+ while (c.moveToNext()) decryptOrNull(service, c.getString(0))?.let { keys.add(it) }
285
+ }
286
+ return keys
287
+ }
288
+
289
+ // ── sqlite store (plaintext key/value table) ──────────────────────────────────
290
+
291
+ private val dbHelper: SQLiteOpenHelper by lazy {
292
+ object : SQLiteOpenHelper(reactContext, "okint_sqlite.db", null, 1) {
293
+ override fun onCreate(db: SQLiteDatabase) {}
294
+ override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {}
295
+ }
296
+ }
297
+
298
+ private fun kvTable(service: String): String = "kv_" + service.replace(Regex("[^A-Za-z0-9_]"), "_")
299
+
300
+ private fun ensureKv(db: SQLiteDatabase, service: String) {
301
+ db.execSQL("CREATE TABLE IF NOT EXISTS ${kvTable(service)} (k TEXT PRIMARY KEY, v TEXT NOT NULL)")
302
+ }
303
+
304
+ @Synchronized
305
+ private fun sqliteSet(service: String, key: String, value: String) {
306
+ val db = dbHelper.writableDatabase
307
+ ensureKv(db, service)
308
+ db.execSQL("INSERT OR REPLACE INTO ${kvTable(service)} (k, v) VALUES (?, ?)", arrayOf(key, value))
309
+ }
310
+
311
+ @Synchronized
312
+ private fun sqliteGet(service: String, key: String): String? {
313
+ val db = dbHelper.readableDatabase
314
+ ensureKv(db, service)
315
+ db.rawQuery("SELECT v FROM ${kvTable(service)} WHERE k = ?", arrayOf(key)).use { c ->
316
+ return if (c.moveToFirst()) c.getString(0) else null
317
+ }
318
+ }
319
+
320
+ @Synchronized
321
+ private fun sqliteExec(service: String, sql: String, args: Array<String>) {
322
+ val db = dbHelper.writableDatabase
323
+ ensureKv(db, service)
324
+ if (args.isEmpty()) db.execSQL(sql) else db.execSQL(sql, args)
325
+ }
326
+
327
+ @Synchronized
328
+ private fun sqliteKeys(service: String): List<String> {
329
+ val db = dbHelper.readableDatabase
330
+ ensureKv(db, service)
331
+ val keys = ArrayList<String>()
332
+ db.rawQuery("SELECT k FROM ${kvTable(service)}", emptyArray()).use { c ->
333
+ while (c.moveToNext()) keys.add(c.getString(0))
334
+ }
335
+ return keys
336
+ }
337
+
338
+ companion object {
339
+ init {
340
+ try {
341
+ System.loadLibrary("okint")
342
+ } catch (ignored: Throwable) {
343
+ // JSI engine optional — installJSI() returns false if the lib is absent.
344
+ }
345
+ }
346
+
347
+ const val NAME = "OkintRnStorage"
348
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
349
+ private const val STORE_SECURE = "secure"
350
+ private const val STORE_ASYNC = "async"
351
+ private const val STORE_ENCRYPTED = "encrypted"
352
+ private const val STORE_SQLITE = "sqlite"
353
+ }
354
+ }
@@ -0,0 +1,14 @@
1
+ package com.okint.rnstorage
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class OkintRnStoragePackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10
+ listOf(OkintRnStorageModule(reactContext))
11
+
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13
+ emptyList()
14
+ }