@okint-digital/okint-rn-storage 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +275 -232
- package/android/build.gradle +27 -10
- package/android/src/main/java/com/okint/rnstorage/OkintRnStorageModule.kt +560 -519
- 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
|
@@ -1,519 +1,560 @@
|
|
|
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.os.Build
|
|
8
|
-
import android.security.keystore.KeyGenParameterSpec
|
|
9
|
-
import android.security.keystore.KeyProperties
|
|
10
|
-
import android.util.Base64
|
|
11
|
-
import com.facebook.react.bridge.Arguments
|
|
12
|
-
import com.facebook.react.bridge.Promise
|
|
13
|
-
import com.facebook.react.bridge.ReactApplicationContext
|
|
14
|
-
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
15
|
-
import com.facebook.react.bridge.ReactMethod
|
|
16
|
-
import com.facebook.react.bridge.WritableMap
|
|
17
|
-
import java.security.KeyStore
|
|
18
|
-
import java.util.concurrent.ConcurrentHashMap
|
|
19
|
-
import javax.crypto.Cipher
|
|
20
|
-
import javax.crypto.KeyGenerator
|
|
21
|
-
import javax.crypto.Mac
|
|
22
|
-
import javax.crypto.SecretKey
|
|
23
|
-
import javax.crypto.spec.GCMParameterSpec
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* okint-rn-storage — Android native module. One module, four stores, ZERO
|
|
27
|
-
* third-party dependencies (only the Android platform + AndroidKeystore):
|
|
28
|
-
*
|
|
29
|
-
* - "secure" → AES-256-GCM with a per-namespace, non-exportable
|
|
30
|
-
* AndroidKeystore key (hardware-backed where available);
|
|
31
|
-
* ciphertext lives in plain SharedPreferences. This is the
|
|
32
|
-
* construction EncryptedSharedPreferences used internally,
|
|
33
|
-
* without the deprecated androidx.security dependency. A
|
|
34
|
-
* decrypt failure (restored backup, invalidated key) returns
|
|
35
|
-
* null instead of crashing at launch — crash-recovery built in.
|
|
36
|
-
* - "async" → plain SharedPreferences (fast, unencrypted).
|
|
37
|
-
* - "encrypted" → a fully-encrypted SQLite table: both KEYS and VALUES are
|
|
38
|
-
* AES-256-GCM encrypted; lookups use a deterministic HMAC
|
|
39
|
-
* token (Keystore HMAC key). No plaintext in the database — an
|
|
40
|
-
* encrypted DB with no SQLCipher dependency, sized for large
|
|
41
|
-
* blobs / many entries.
|
|
42
|
-
* - "sqlite" → plaintext values in a separate SQLite table.
|
|
43
|
-
*
|
|
44
|
-
* Plus a blocking-sync bulk read (`getEntriesSync`) and a C++/JSI fast-path
|
|
45
|
-
* installer (`installJSI`).
|
|
46
|
-
*
|
|
47
|
-
* NOTE: native code is written against the stable Android crypto/Keystore APIs
|
|
48
|
-
* and verified at app build time (no Gradle/NDK in the authoring environment).
|
|
49
|
-
*/
|
|
50
|
-
class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
|
|
51
|
-
ReactContextBaseJavaModule(reactContext) {
|
|
52
|
-
|
|
53
|
-
override fun getName(): String = NAME
|
|
54
|
-
|
|
55
|
-
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
@ReactMethod
|
|
58
|
-
fun setItem(service: String, key: String, value: String, store: String, requireAuth: Boolean, promise: Promise) {
|
|
59
|
-
if (store == STORE_SECURE && requireAuth) {
|
|
60
|
-
secureSetAuth(service, key, value, promise)
|
|
61
|
-
return
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
when (store) {
|
|
65
|
-
STORE_SECURE -> securePrefs(service).edit().putString(key, encrypt(service, value)).commit()
|
|
66
|
-
STORE_ENCRYPTED -> encSet(service, key, value)
|
|
67
|
-
STORE_SQLITE -> sqliteSet(service, key, value)
|
|
68
|
-
else -> asyncPrefs(service).edit().putString(key, value).commit()
|
|
69
|
-
}
|
|
70
|
-
promise.resolve(null)
|
|
71
|
-
} catch (e: Exception) {
|
|
72
|
-
promise.reject("E_OKINT_SET", e.message, e)
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
@ReactMethod
|
|
77
|
-
fun getItem(service: String, key: String, store: String, requireAuth: Boolean, promise: Promise) {
|
|
78
|
-
if (store == STORE_SECURE && requireAuth) {
|
|
79
|
-
secureGetAuth(service, key, promise)
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
try {
|
|
83
|
-
promise.resolve(readOne(service, key, store))
|
|
84
|
-
} catch (e: Exception) {
|
|
85
|
-
promise.reject("E_OKINT_GET", e.message, e)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
@ReactMethod
|
|
90
|
-
fun removeItem(service: String, key: String, store: String, promise: Promise) {
|
|
91
|
-
try {
|
|
92
|
-
when (store) {
|
|
93
|
-
STORE_SECURE -> securePrefs(service).edit().remove(key).commit()
|
|
94
|
-
STORE_ENCRYPTED -> encExec(service, "DELETE FROM ${encTable(service)} WHERE kt=?", arrayOf(token(service, key)))
|
|
95
|
-
STORE_SQLITE -> sqliteExec(service, "DELETE FROM ${kvTable(service)} WHERE k=?", arrayOf(key))
|
|
96
|
-
else -> asyncPrefs(service).edit().remove(key).commit()
|
|
97
|
-
}
|
|
98
|
-
promise.resolve(null)
|
|
99
|
-
} catch (e: Exception) {
|
|
100
|
-
promise.reject("E_OKINT_REMOVE", e.message, e)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
@ReactMethod
|
|
105
|
-
fun clear(service: String, store: String, promise: Promise) {
|
|
106
|
-
try {
|
|
107
|
-
when (store) {
|
|
108
|
-
STORE_SECURE -> securePrefs(service).edit().clear().commit()
|
|
109
|
-
STORE_ENCRYPTED -> encExec(service, "DELETE FROM ${encTable(service)}", emptyArray())
|
|
110
|
-
STORE_SQLITE -> sqliteExec(service, "DELETE FROM ${kvTable(service)}", emptyArray())
|
|
111
|
-
else -> asyncPrefs(service).edit().clear().commit()
|
|
112
|
-
}
|
|
113
|
-
promise.resolve(null)
|
|
114
|
-
} catch (e: Exception) {
|
|
115
|
-
promise.reject("E_OKINT_CLEAR", e.message, e)
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
@ReactMethod
|
|
120
|
-
fun getAllKeys(service: String, store: String, promise: Promise) {
|
|
121
|
-
try {
|
|
122
|
-
val arr = Arguments.createArray()
|
|
123
|
-
for (k in allKeys(service, store)) arr.pushString(k)
|
|
124
|
-
promise.resolve(arr)
|
|
125
|
-
} catch (e: Exception) {
|
|
126
|
-
promise.reject("E_OKINT_KEYS", e.message, e)
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Blocking-synchronous bulk read for the zero-load sync store. */
|
|
131
|
-
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
132
|
-
fun getEntriesSync(service: String, store: String): WritableMap {
|
|
133
|
-
val map = Arguments.createMap()
|
|
134
|
-
try {
|
|
135
|
-
when (store) {
|
|
136
|
-
STORE_ASYNC -> for ((k, v) in asyncPrefs(service).all) {
|
|
137
|
-
if (v is String) map.putString(k, v)
|
|
138
|
-
}
|
|
139
|
-
else -> for (k in allKeys(service, store)) {
|
|
140
|
-
readOne(service, k, store)?.let { map.putString(k, it) }
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
} catch (ignored: Exception) {
|
|
144
|
-
}
|
|
145
|
-
return map
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Install the C++/JSI fast-path engine. Returns false if the runtime is unreachable. */
|
|
149
|
-
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
150
|
-
fun installJSI(): Boolean {
|
|
151
|
-
return try {
|
|
152
|
-
val ptr = reactContext.javaScriptContextHolder?.get() ?: return false
|
|
153
|
-
if (ptr == 0L) return false
|
|
154
|
-
nativeInstallJSI(ptr, reactContext.filesDir.absolutePath)
|
|
155
|
-
true
|
|
156
|
-
} catch (e: Throwable) {
|
|
157
|
-
false
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
private external fun nativeInstallJSI(jsiPtr: Long, dir: String)
|
|
162
|
-
|
|
163
|
-
// ── Shared read helpers ──────────────────────────────────────────────────────
|
|
164
|
-
|
|
165
|
-
private fun readOne(service: String, key: String, store: String): String? = when (store) {
|
|
166
|
-
STORE_SECURE -> securePrefs(service).getString(key, null)?.let { decryptOrNull(service, it) }
|
|
167
|
-
STORE_ENCRYPTED -> encGet(service, key)
|
|
168
|
-
STORE_SQLITE -> sqliteGet(service, key)
|
|
169
|
-
else -> asyncPrefs(service).getString(key, null)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private fun allKeys(service: String, store: String): List<String> = when (store) {
|
|
173
|
-
STORE_SECURE -> securePrefs(service).all.keys.toList()
|
|
174
|
-
STORE_ENCRYPTED -> encKeys(service)
|
|
175
|
-
STORE_SQLITE -> sqliteKeys(service)
|
|
176
|
-
else -> asyncPrefs(service).all.keys.toList()
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── SharedPreferences (secure ciphertext + async plaintext) ──────────────────
|
|
180
|
-
|
|
181
|
-
private val prefsCache = ConcurrentHashMap<String, SharedPreferences>()
|
|
182
|
-
|
|
183
|
-
private fun prefs(name: String): SharedPreferences =
|
|
184
|
-
prefsCache.getOrPut(name) { reactContext.getSharedPreferences(name, Context.MODE_PRIVATE) }
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
private fun
|
|
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
|
-
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)
|
|
235
|
-
kg.init(builder.build())
|
|
236
|
-
kg.generateKey()
|
|
237
|
-
} catch (e: Exception) {
|
|
238
|
-
if (strongBox) null else throw e
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private fun
|
|
242
|
-
val
|
|
243
|
-
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private fun
|
|
248
|
-
val
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
265
|
-
cipher.init(Cipher.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
private fun
|
|
466
|
-
db
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
private
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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.os.Build
|
|
8
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
9
|
+
import android.security.keystore.KeyProperties
|
|
10
|
+
import android.util.Base64
|
|
11
|
+
import com.facebook.react.bridge.Arguments
|
|
12
|
+
import com.facebook.react.bridge.Promise
|
|
13
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
14
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
15
|
+
import com.facebook.react.bridge.ReactMethod
|
|
16
|
+
import com.facebook.react.bridge.WritableMap
|
|
17
|
+
import java.security.KeyStore
|
|
18
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
19
|
+
import javax.crypto.Cipher
|
|
20
|
+
import javax.crypto.KeyGenerator
|
|
21
|
+
import javax.crypto.Mac
|
|
22
|
+
import javax.crypto.SecretKey
|
|
23
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* okint-rn-storage — Android native module. One module, four stores, ZERO
|
|
27
|
+
* third-party dependencies (only the Android platform + AndroidKeystore):
|
|
28
|
+
*
|
|
29
|
+
* - "secure" → AES-256-GCM with a per-namespace, non-exportable
|
|
30
|
+
* AndroidKeystore key (hardware-backed where available);
|
|
31
|
+
* ciphertext lives in plain SharedPreferences. This is the
|
|
32
|
+
* construction EncryptedSharedPreferences used internally,
|
|
33
|
+
* without the deprecated androidx.security dependency. A
|
|
34
|
+
* decrypt failure (restored backup, invalidated key) returns
|
|
35
|
+
* null instead of crashing at launch — crash-recovery built in.
|
|
36
|
+
* - "async" → plain SharedPreferences (fast, unencrypted).
|
|
37
|
+
* - "encrypted" → a fully-encrypted SQLite table: both KEYS and VALUES are
|
|
38
|
+
* AES-256-GCM encrypted; lookups use a deterministic HMAC
|
|
39
|
+
* token (Keystore HMAC key). No plaintext in the database — an
|
|
40
|
+
* encrypted DB with no SQLCipher dependency, sized for large
|
|
41
|
+
* blobs / many entries.
|
|
42
|
+
* - "sqlite" → plaintext values in a separate SQLite table.
|
|
43
|
+
*
|
|
44
|
+
* Plus a blocking-sync bulk read (`getEntriesSync`) and a C++/JSI fast-path
|
|
45
|
+
* installer (`installJSI`).
|
|
46
|
+
*
|
|
47
|
+
* NOTE: native code is written against the stable Android crypto/Keystore APIs
|
|
48
|
+
* and verified at app build time (no Gradle/NDK in the authoring environment).
|
|
49
|
+
*/
|
|
50
|
+
class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
|
|
51
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
52
|
+
|
|
53
|
+
override fun getName(): String = NAME
|
|
54
|
+
|
|
55
|
+
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
@ReactMethod
|
|
58
|
+
fun setItem(service: String, key: String, value: String, store: String, requireAuth: Boolean, promise: Promise) {
|
|
59
|
+
if (store == STORE_SECURE && requireAuth) {
|
|
60
|
+
secureSetAuth(service, key, value, promise)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
when (store) {
|
|
65
|
+
STORE_SECURE -> securePrefs(service).edit().putString(key, encrypt(service, value)).commit()
|
|
66
|
+
STORE_ENCRYPTED -> encSet(service, key, value)
|
|
67
|
+
STORE_SQLITE -> sqliteSet(service, key, value)
|
|
68
|
+
else -> asyncPrefs(service).edit().putString(key, value).commit()
|
|
69
|
+
}
|
|
70
|
+
promise.resolve(null)
|
|
71
|
+
} catch (e: Exception) {
|
|
72
|
+
promise.reject("E_OKINT_SET", e.message, e)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@ReactMethod
|
|
77
|
+
fun getItem(service: String, key: String, store: String, requireAuth: Boolean, promise: Promise) {
|
|
78
|
+
if (store == STORE_SECURE && requireAuth) {
|
|
79
|
+
secureGetAuth(service, key, promise)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
promise.resolve(readOne(service, key, store))
|
|
84
|
+
} catch (e: Exception) {
|
|
85
|
+
promise.reject("E_OKINT_GET", e.message, e)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@ReactMethod
|
|
90
|
+
fun removeItem(service: String, key: String, store: String, promise: Promise) {
|
|
91
|
+
try {
|
|
92
|
+
when (store) {
|
|
93
|
+
STORE_SECURE -> securePrefs(service).edit().remove(key).commit()
|
|
94
|
+
STORE_ENCRYPTED -> encExec(service, "DELETE FROM ${encTable(service)} WHERE kt=?", arrayOf(token(service, key)))
|
|
95
|
+
STORE_SQLITE -> sqliteExec(service, "DELETE FROM ${kvTable(service)} WHERE k=?", arrayOf(key))
|
|
96
|
+
else -> asyncPrefs(service).edit().remove(key).commit()
|
|
97
|
+
}
|
|
98
|
+
promise.resolve(null)
|
|
99
|
+
} catch (e: Exception) {
|
|
100
|
+
promise.reject("E_OKINT_REMOVE", e.message, e)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@ReactMethod
|
|
105
|
+
fun clear(service: String, store: String, promise: Promise) {
|
|
106
|
+
try {
|
|
107
|
+
when (store) {
|
|
108
|
+
STORE_SECURE -> securePrefs(service).edit().clear().commit()
|
|
109
|
+
STORE_ENCRYPTED -> encExec(service, "DELETE FROM ${encTable(service)}", emptyArray())
|
|
110
|
+
STORE_SQLITE -> sqliteExec(service, "DELETE FROM ${kvTable(service)}", emptyArray())
|
|
111
|
+
else -> asyncPrefs(service).edit().clear().commit()
|
|
112
|
+
}
|
|
113
|
+
promise.resolve(null)
|
|
114
|
+
} catch (e: Exception) {
|
|
115
|
+
promise.reject("E_OKINT_CLEAR", e.message, e)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@ReactMethod
|
|
120
|
+
fun getAllKeys(service: String, store: String, promise: Promise) {
|
|
121
|
+
try {
|
|
122
|
+
val arr = Arguments.createArray()
|
|
123
|
+
for (k in allKeys(service, store)) arr.pushString(k)
|
|
124
|
+
promise.resolve(arr)
|
|
125
|
+
} catch (e: Exception) {
|
|
126
|
+
promise.reject("E_OKINT_KEYS", e.message, e)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Blocking-synchronous bulk read for the zero-load sync store. */
|
|
131
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
132
|
+
fun getEntriesSync(service: String, store: String): WritableMap {
|
|
133
|
+
val map = Arguments.createMap()
|
|
134
|
+
try {
|
|
135
|
+
when (store) {
|
|
136
|
+
STORE_ASYNC -> for ((k, v) in asyncPrefs(service).all) {
|
|
137
|
+
if (v is String) map.putString(k, v)
|
|
138
|
+
}
|
|
139
|
+
else -> for (k in allKeys(service, store)) {
|
|
140
|
+
readOne(service, k, store)?.let { map.putString(k, it) }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (ignored: Exception) {
|
|
144
|
+
}
|
|
145
|
+
return map
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Install the C++/JSI fast-path engine. Returns false if the runtime is unreachable. */
|
|
149
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
150
|
+
fun installJSI(): Boolean {
|
|
151
|
+
return try {
|
|
152
|
+
val ptr = reactContext.javaScriptContextHolder?.get() ?: return false
|
|
153
|
+
if (ptr == 0L) return false
|
|
154
|
+
nativeInstallJSI(ptr, reactContext.filesDir.absolutePath)
|
|
155
|
+
true
|
|
156
|
+
} catch (e: Throwable) {
|
|
157
|
+
false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private external fun nativeInstallJSI(jsiPtr: Long, dir: String)
|
|
162
|
+
|
|
163
|
+
// ── Shared read helpers ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
private fun readOne(service: String, key: String, store: String): String? = when (store) {
|
|
166
|
+
STORE_SECURE -> securePrefs(service).getString(key, null)?.let { decryptOrNull(service, it) }
|
|
167
|
+
STORE_ENCRYPTED -> encGet(service, key)
|
|
168
|
+
STORE_SQLITE -> sqliteGet(service, key)
|
|
169
|
+
else -> asyncPrefs(service).getString(key, null)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun allKeys(service: String, store: String): List<String> = when (store) {
|
|
173
|
+
STORE_SECURE -> securePrefs(service).all.keys.toList()
|
|
174
|
+
STORE_ENCRYPTED -> encKeys(service)
|
|
175
|
+
STORE_SQLITE -> sqliteKeys(service)
|
|
176
|
+
else -> asyncPrefs(service).all.keys.toList()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── SharedPreferences (secure ciphertext + async plaintext) ──────────────────
|
|
180
|
+
|
|
181
|
+
private val prefsCache = ConcurrentHashMap<String, SharedPreferences>()
|
|
182
|
+
|
|
183
|
+
private fun prefs(name: String): SharedPreferences =
|
|
184
|
+
prefsCache.getOrPut(name) { reactContext.getSharedPreferences(name, Context.MODE_PRIVATE) }
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Defense-in-depth: the JS layer restricts namespaces to [A-Za-z0-9_], but the
|
|
188
|
+
* native module is also reachable directly via NativeModules. Re-validate so a
|
|
189
|
+
* direct caller cannot pass "." / "-" — which would otherwise collapse to "_" in
|
|
190
|
+
* the SQLite table name and let two distinct namespaces share one table.
|
|
191
|
+
*/
|
|
192
|
+
private fun assertSafeService(service: String) {
|
|
193
|
+
require(SAFE_SERVICE.matches(service)) { "Invalid namespace (allowed: [A-Za-z0-9_], 1-200 chars)" }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private fun asyncPrefs(service: String): SharedPreferences {
|
|
197
|
+
assertSafeService(service)
|
|
198
|
+
return prefs("okint_$service")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private fun securePrefs(service: String): SharedPreferences {
|
|
202
|
+
assertSafeService(service)
|
|
203
|
+
return prefs("okint_secure_$service")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Crypto core (AES-256-GCM + HMAC token, rooted in AndroidKeystore) ────────
|
|
207
|
+
|
|
208
|
+
private fun loadKey(alias: String): SecretKey? {
|
|
209
|
+
val ks = KeyStore.getInstance(ANDROID_KEYSTORE)
|
|
210
|
+
ks.load(null)
|
|
211
|
+
return ks.getKey(alias, null) as? SecretKey
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Generate a Keystore key, preferring the dedicated **StrongBox** secure
|
|
216
|
+
* element (Titan M / SE) when present and falling back to the TEE otherwise.
|
|
217
|
+
* Some devices advertise StrongBox but fail at generation time, so we catch
|
|
218
|
+
* broadly on the StrongBox attempt and retry without it — the documented
|
|
219
|
+
* pattern. Existing keys are loaded by alias first, so this never re-keys an
|
|
220
|
+
* install; only brand-new keys gain StrongBox backing.
|
|
221
|
+
*/
|
|
222
|
+
private fun aesKey(service: String): SecretKey {
|
|
223
|
+
val alias = "okint_enckey_$service"
|
|
224
|
+
loadKey(alias)?.let { return it }
|
|
225
|
+
return generateAesKey(alias, strongBox = true) ?: generateAesKey(alias, strongBox = false)!!
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey? = try {
|
|
229
|
+
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
|
230
|
+
val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
|
231
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
232
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
233
|
+
.setKeySize(256)
|
|
234
|
+
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)
|
|
235
|
+
kg.init(builder.build())
|
|
236
|
+
kg.generateKey()
|
|
237
|
+
} catch (e: Exception) {
|
|
238
|
+
if (strongBox) null else throw e
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private fun hmacKey(service: String): SecretKey {
|
|
242
|
+
val alias = "okint_enchmac_$service"
|
|
243
|
+
loadKey(alias)?.let { return it }
|
|
244
|
+
return generateHmacKey(alias, strongBox = true) ?: generateHmacKey(alias, strongBox = false)!!
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private fun generateHmacKey(alias: String, strongBox: Boolean): SecretKey? = try {
|
|
248
|
+
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE)
|
|
249
|
+
val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
|
|
250
|
+
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)
|
|
251
|
+
kg.init(builder.build())
|
|
252
|
+
kg.generateKey()
|
|
253
|
+
} catch (e: Exception) {
|
|
254
|
+
if (strongBox) null else throw e
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fun token(service: String, key: String): String {
|
|
258
|
+
val mac = Mac.getInstance("HmacSHA256")
|
|
259
|
+
mac.init(hmacKey(service))
|
|
260
|
+
return Base64.encodeToString(mac.doFinal(key.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun encrypt(service: String, plaintext: String): String {
|
|
264
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
265
|
+
cipher.init(Cipher.ENCRYPT_MODE, aesKey(service))
|
|
266
|
+
val iv = cipher.iv
|
|
267
|
+
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
|
268
|
+
val out = ByteArray(1 + iv.size + ct.size)
|
|
269
|
+
out[0] = iv.size.toByte()
|
|
270
|
+
System.arraycopy(iv, 0, out, 1, iv.size)
|
|
271
|
+
System.arraycopy(ct, 0, out, 1 + iv.size, ct.size)
|
|
272
|
+
return Base64.encodeToString(out, Base64.NO_WRAP)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private fun decrypt(service: String, b64: String): String {
|
|
276
|
+
val data = Base64.decode(b64, Base64.NO_WRAP)
|
|
277
|
+
// Bounds-check the [ivLen|iv|ct] framing before slicing so malformed/corrupt
|
|
278
|
+
// input fails closed (caught by decryptOrNull → null) instead of throwing an
|
|
279
|
+
// index exception. GCM IV is 12 bytes; allow 12..16 defensively.
|
|
280
|
+
require(data.size >= 2) { "ciphertext too short" }
|
|
281
|
+
val ivLen = data[0].toInt() and 0xFF
|
|
282
|
+
require(ivLen in 12..16 && 1 + ivLen < data.size) { "bad IV framing" }
|
|
283
|
+
val iv = data.copyOfRange(1, 1 + ivLen)
|
|
284
|
+
val ct = data.copyOfRange(1 + ivLen, data.size)
|
|
285
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
286
|
+
cipher.init(Cipher.DECRYPT_MODE, aesKey(service), GCMParameterSpec(128, iv))
|
|
287
|
+
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Decrypt that tolerates a lost/rotated key (restored backup) — returns null, never crashes. */
|
|
291
|
+
private fun decryptOrNull(service: String, b64: String): String? = try {
|
|
292
|
+
decrypt(service, b64)
|
|
293
|
+
} catch (e: Exception) {
|
|
294
|
+
null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── secure + requireAuth (biometric-gated AES key, per-operation CryptoObject)
|
|
298
|
+
//
|
|
299
|
+
// Opt-in path (createStorage({ backend:'secure', requireAuth:true })). The AES
|
|
300
|
+
// key is `setUserAuthenticationRequired`, so every encrypt/decrypt must run
|
|
301
|
+
// through a framework BiometricPrompt bound to the operation's Cipher. Uses a
|
|
302
|
+
// distinct key alias so it never collides with the non-gated secure key;
|
|
303
|
+
// ciphertext lives in the same SharedPreferences file, so remove/clear/keys
|
|
304
|
+
// work unchanged. Strong biometric only (CryptoObject can't combine with
|
|
305
|
+
// device-credential); API 28+. NOTE: the BiometricPrompt UI cannot be
|
|
306
|
+
// exercised without a real device — this path is build-verified on-device.
|
|
307
|
+
|
|
308
|
+
private fun secureAuthAesKey(service: String): SecretKey {
|
|
309
|
+
val alias = "okint_secauth_$service"
|
|
310
|
+
loadKey(alias)?.let { return it }
|
|
311
|
+
return generateAuthAesKey(alias, strongBox = true) ?: generateAuthAesKey(alias, strongBox = false)!!
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private fun generateAuthAesKey(alias: String, strongBox: Boolean): SecretKey? = try {
|
|
315
|
+
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
|
316
|
+
val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
|
317
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
318
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
319
|
+
.setKeySize(256)
|
|
320
|
+
.setUserAuthenticationRequired(true)
|
|
321
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
322
|
+
// Timeout 0 → every use requires a fresh auth via CryptoObject.
|
|
323
|
+
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
|
|
324
|
+
}
|
|
325
|
+
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)
|
|
326
|
+
kg.init(builder.build())
|
|
327
|
+
kg.generateKey()
|
|
328
|
+
} catch (e: Exception) {
|
|
329
|
+
if (strongBox) null else throw e
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private fun secureSetAuth(service: String, key: String, value: String, promise: Promise) {
|
|
333
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
334
|
+
promise.reject("E_OKINT_AUTH", "requireAuth needs Android 9 (API 28)+")
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
339
|
+
cipher.init(Cipher.ENCRYPT_MODE, secureAuthAesKey(service))
|
|
340
|
+
authenticate(cipher, "Authenticate to save", promise) { authed ->
|
|
341
|
+
val iv = authed.iv
|
|
342
|
+
val ct = authed.doFinal(value.toByteArray(Charsets.UTF_8))
|
|
343
|
+
val out = ByteArray(1 + iv.size + ct.size)
|
|
344
|
+
out[0] = iv.size.toByte()
|
|
345
|
+
System.arraycopy(iv, 0, out, 1, iv.size)
|
|
346
|
+
System.arraycopy(ct, 0, out, 1 + iv.size, ct.size)
|
|
347
|
+
securePrefs(service).edit().putString(key, Base64.encodeToString(out, Base64.NO_WRAP)).commit()
|
|
348
|
+
null
|
|
349
|
+
}
|
|
350
|
+
} catch (e: Exception) {
|
|
351
|
+
promise.reject("E_OKINT_SET", e.message, e)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private fun secureGetAuth(service: String, key: String, promise: Promise) {
|
|
356
|
+
// securePrefs() validates the namespace and can throw; this runs before the
|
|
357
|
+
// try below (and getItem dispatches here before ITS try), so reject cleanly
|
|
358
|
+
// rather than letting the throw escape and leave the promise unsettled.
|
|
359
|
+
val raw = try {
|
|
360
|
+
securePrefs(service).getString(key, null)
|
|
361
|
+
} catch (e: Exception) {
|
|
362
|
+
promise.reject("E_OKINT_GET", e.message, e)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
if (raw == null) {
|
|
366
|
+
promise.resolve(null)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
|
370
|
+
promise.reject("E_OKINT_AUTH", "requireAuth needs Android 9 (API 28)+")
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
val data = Base64.decode(raw, Base64.NO_WRAP)
|
|
375
|
+
require(data.size >= 2) { "ciphertext too short" }
|
|
376
|
+
val ivLen = data[0].toInt() and 0xFF
|
|
377
|
+
require(ivLen in 12..16 && 1 + ivLen < data.size) { "bad IV framing" }
|
|
378
|
+
val iv = data.copyOfRange(1, 1 + ivLen)
|
|
379
|
+
val ct = data.copyOfRange(1 + ivLen, data.size)
|
|
380
|
+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
|
381
|
+
cipher.init(Cipher.DECRYPT_MODE, secureAuthAesKey(service), GCMParameterSpec(128, iv))
|
|
382
|
+
authenticate(cipher, "Authenticate to access", promise) { authed ->
|
|
383
|
+
String(authed.doFinal(ct), Charsets.UTF_8)
|
|
384
|
+
}
|
|
385
|
+
} catch (e: Exception) {
|
|
386
|
+
promise.reject("E_OKINT_GET", e.message, e)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Present a framework BiometricPrompt bound to [cipher]; on success run
|
|
392
|
+
* [onAuthed] with the authenticated cipher and resolve [promise] with its
|
|
393
|
+
* result. Runs on the UI thread (BiometricPrompt requirement).
|
|
394
|
+
*/
|
|
395
|
+
@androidx.annotation.RequiresApi(Build.VERSION_CODES.P)
|
|
396
|
+
private fun authenticate(cipher: Cipher, title: String, promise: Promise, onAuthed: (Cipher) -> String?) {
|
|
397
|
+
// The current Activity lives on the React context, not on the module base class.
|
|
398
|
+
val activity = reactContext.currentActivity
|
|
399
|
+
if (activity == null) {
|
|
400
|
+
promise.reject("E_OKINT_AUTH", "No foreground Activity to present the authentication prompt")
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
activity.runOnUiThread {
|
|
404
|
+
try {
|
|
405
|
+
val executor = activity.mainExecutor
|
|
406
|
+
val builder = android.hardware.biometrics.BiometricPrompt.Builder(activity).setTitle(title)
|
|
407
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
408
|
+
builder.setAllowedAuthenticators(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
|
409
|
+
} else {
|
|
410
|
+
builder.setNegativeButton("Cancel", executor) { _, _ ->
|
|
411
|
+
promise.reject("E_OKINT_AUTH_CANCELLED", "Authentication cancelled")
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
val prompt = builder.build()
|
|
415
|
+
val crypto = android.hardware.biometrics.BiometricPrompt.CryptoObject(cipher)
|
|
416
|
+
prompt.authenticate(
|
|
417
|
+
crypto,
|
|
418
|
+
android.os.CancellationSignal(),
|
|
419
|
+
executor,
|
|
420
|
+
object : android.hardware.biometrics.BiometricPrompt.AuthenticationCallback() {
|
|
421
|
+
override fun onAuthenticationSucceeded(result: android.hardware.biometrics.BiometricPrompt.AuthenticationResult) {
|
|
422
|
+
try {
|
|
423
|
+
val authedCipher = result.cryptoObject?.cipher ?: cipher
|
|
424
|
+
promise.resolve(onAuthed(authedCipher))
|
|
425
|
+
} catch (e: Exception) {
|
|
426
|
+
promise.reject("E_OKINT_AUTH_CRYPTO", e.message, e)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
431
|
+
promise.reject("E_OKINT_AUTH", errString.toString())
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
} catch (e: Exception) {
|
|
436
|
+
promise.reject("E_OKINT_AUTH", e.message, e)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── encrypted store (enc_ table: HMAC token + encrypted key + encrypted value)
|
|
442
|
+
|
|
443
|
+
// Service is validated to [A-Za-z0-9_] (assertSafeService), so it is used
|
|
444
|
+
// verbatim in the table name — injective, no lossy collapse, no collision.
|
|
445
|
+
private fun encTable(service: String): String {
|
|
446
|
+
assertSafeService(service)
|
|
447
|
+
return "enc_$service"
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private fun ensureEnc(db: SQLiteDatabase, service: String) {
|
|
451
|
+
db.execSQL("CREATE TABLE IF NOT EXISTS ${encTable(service)} (kt TEXT PRIMARY KEY, ke TEXT NOT NULL, ve TEXT NOT NULL)")
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
@Synchronized
|
|
455
|
+
private fun encSet(service: String, key: String, value: String) {
|
|
456
|
+
val db = dbHelper.writableDatabase
|
|
457
|
+
ensureEnc(db, service)
|
|
458
|
+
db.execSQL(
|
|
459
|
+
"INSERT OR REPLACE INTO ${encTable(service)} (kt, ke, ve) VALUES (?, ?, ?)",
|
|
460
|
+
arrayOf(token(service, key), encrypt(service, key), encrypt(service, value)),
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@Synchronized
|
|
465
|
+
private fun encGet(service: String, key: String): String? {
|
|
466
|
+
val db = dbHelper.readableDatabase
|
|
467
|
+
ensureEnc(db, service)
|
|
468
|
+
db.rawQuery("SELECT ve FROM ${encTable(service)} WHERE kt = ?", arrayOf(token(service, key))).use { c ->
|
|
469
|
+
return if (c.moveToFirst()) decryptOrNull(service, c.getString(0)) else null
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@Synchronized
|
|
474
|
+
private fun encExec(service: String, sql: String, args: Array<String>) {
|
|
475
|
+
val db = dbHelper.writableDatabase
|
|
476
|
+
ensureEnc(db, service)
|
|
477
|
+
if (args.isEmpty()) db.execSQL(sql) else db.execSQL(sql, args)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@Synchronized
|
|
481
|
+
private fun encKeys(service: String): List<String> {
|
|
482
|
+
val db = dbHelper.readableDatabase
|
|
483
|
+
ensureEnc(db, service)
|
|
484
|
+
val keys = ArrayList<String>()
|
|
485
|
+
db.rawQuery("SELECT ke FROM ${encTable(service)}", emptyArray()).use { c ->
|
|
486
|
+
while (c.moveToNext()) decryptOrNull(service, c.getString(0))?.let { keys.add(it) }
|
|
487
|
+
}
|
|
488
|
+
return keys
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── sqlite store (plaintext key/value table) ──────────────────────────────────
|
|
492
|
+
|
|
493
|
+
private val dbHelper: SQLiteOpenHelper by lazy {
|
|
494
|
+
object : SQLiteOpenHelper(reactContext, "okint_sqlite.db", null, 1) {
|
|
495
|
+
override fun onCreate(db: SQLiteDatabase) {}
|
|
496
|
+
override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private fun kvTable(service: String): String {
|
|
501
|
+
assertSafeService(service)
|
|
502
|
+
return "kv_$service"
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private fun ensureKv(db: SQLiteDatabase, service: String) {
|
|
506
|
+
db.execSQL("CREATE TABLE IF NOT EXISTS ${kvTable(service)} (k TEXT PRIMARY KEY, v TEXT NOT NULL)")
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
@Synchronized
|
|
510
|
+
private fun sqliteSet(service: String, key: String, value: String) {
|
|
511
|
+
val db = dbHelper.writableDatabase
|
|
512
|
+
ensureKv(db, service)
|
|
513
|
+
db.execSQL("INSERT OR REPLACE INTO ${kvTable(service)} (k, v) VALUES (?, ?)", arrayOf(key, value))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
@Synchronized
|
|
517
|
+
private fun sqliteGet(service: String, key: String): String? {
|
|
518
|
+
val db = dbHelper.readableDatabase
|
|
519
|
+
ensureKv(db, service)
|
|
520
|
+
db.rawQuery("SELECT v FROM ${kvTable(service)} WHERE k = ?", arrayOf(key)).use { c ->
|
|
521
|
+
return if (c.moveToFirst()) c.getString(0) else null
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
@Synchronized
|
|
526
|
+
private fun sqliteExec(service: String, sql: String, args: Array<String>) {
|
|
527
|
+
val db = dbHelper.writableDatabase
|
|
528
|
+
ensureKv(db, service)
|
|
529
|
+
if (args.isEmpty()) db.execSQL(sql) else db.execSQL(sql, args)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
@Synchronized
|
|
533
|
+
private fun sqliteKeys(service: String): List<String> {
|
|
534
|
+
val db = dbHelper.readableDatabase
|
|
535
|
+
ensureKv(db, service)
|
|
536
|
+
val keys = ArrayList<String>()
|
|
537
|
+
db.rawQuery("SELECT k FROM ${kvTable(service)}", emptyArray()).use { c ->
|
|
538
|
+
while (c.moveToNext()) keys.add(c.getString(0))
|
|
539
|
+
}
|
|
540
|
+
return keys
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
companion object {
|
|
544
|
+
init {
|
|
545
|
+
try {
|
|
546
|
+
System.loadLibrary("okint")
|
|
547
|
+
} catch (ignored: Throwable) {
|
|
548
|
+
// JSI engine optional — installJSI() returns false if the lib is absent.
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const val NAME = "OkintRnStorage"
|
|
553
|
+
private val SAFE_SERVICE = Regex("^[A-Za-z0-9_]{1,200}$")
|
|
554
|
+
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
|
555
|
+
private const val STORE_SECURE = "secure"
|
|
556
|
+
private const val STORE_ASYNC = "async"
|
|
557
|
+
private const val STORE_ENCRYPTED = "encrypted"
|
|
558
|
+
private const val STORE_SQLITE = "sqlite"
|
|
559
|
+
}
|
|
560
|
+
}
|