@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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/android/build.gradle +59 -0
- package/android/proguard-rules.pro +8 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/okint/rnstorage/OkintRnStorageModule.kt +354 -0
- package/android/src/main/java/com/okint/rnstorage/OkintRnStoragePackage.kt +14 -0
- package/android/src/main/jni/CMakeLists.txt +24 -0
- package/android/src/main/jni/OkintJNI.cpp +15 -0
- package/cpp/OkintJSI.cpp +170 -0
- package/cpp/OkintJSI.h +18 -0
- package/ios/OkintRnStorage.m +457 -0
- package/ios/OkintRnStorageJSI.mm +24 -0
- package/lib/backends/memory.d.ts +16 -0
- package/lib/backends/memory.js +31 -0
- package/lib/backends/native-backend.d.ts +24 -0
- package/lib/backends/native-backend.js +73 -0
- package/lib/errors.d.ts +9 -0
- package/lib/errors.js +19 -0
- package/lib/facade.d.ts +29 -0
- package/lib/facade.js +93 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.js +172 -0
- package/lib/native/bridge.d.ts +12 -0
- package/lib/native/bridge.js +23 -0
- package/lib/native/jsi.d.ts +7 -0
- package/lib/native/jsi.js +33 -0
- package/lib/sync/jsi-store.d.ts +28 -0
- package/lib/sync/jsi-store.js +81 -0
- package/lib/sync/persistence.d.ts +19 -0
- package/lib/sync/persistence.js +49 -0
- package/lib/sync/sync-store.d.ts +49 -0
- package/lib/sync/sync-store.js +159 -0
- package/lib/types.d.ts +140 -0
- package/lib/types.js +10 -0
- package/lib/validate.d.ts +20 -0
- package/lib/validate.js +91 -0
- package/okint-rn-storage.podspec +27 -0
- package/package.json +74 -0
- package/react-native.config.js +15 -0
- package/src/backends/memory.ts +35 -0
- package/src/backends/native-backend.ts +69 -0
- package/src/errors.ts +26 -0
- package/src/facade.ts +118 -0
- package/src/index.ts +194 -0
- package/src/native/bridge.ts +28 -0
- package/src/native/jsi.ts +37 -0
- package/src/sync/jsi-store.ts +98 -0
- package/src/sync/persistence.ts +47 -0
- package/src/sync/sync-store.ts +186 -0
- package/src/types.ts +174 -0
- 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
|
+
}
|