@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.
@@ -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
- private fun asyncPrefs(service: String): SharedPreferences = prefs("okint_$service")
187
-
188
- private fun securePrefs(service: String): SharedPreferences = prefs("okint_secure_$service")
189
-
190
- // ── Crypto core (AES-256-GCM + HMAC token, rooted in AndroidKeystore) ────────
191
-
192
- private fun loadKey(alias: String): SecretKey? {
193
- val ks = KeyStore.getInstance(ANDROID_KEYSTORE)
194
- ks.load(null)
195
- return ks.getKey(alias, null) as? SecretKey
196
- }
197
-
198
- /**
199
- * Generate a Keystore key, preferring the dedicated **StrongBox** secure
200
- * element (Titan M / SE) when present and falling back to the TEE otherwise.
201
- * Some devices advertise StrongBox but fail at generation time, so we catch
202
- * broadly on the StrongBox attempt and retry without it — the documented
203
- * pattern. Existing keys are loaded by alias first, so this never re-keys an
204
- * install; only brand-new keys gain StrongBox backing.
205
- */
206
- private fun aesKey(service: String): SecretKey {
207
- val alias = "okint_enckey_$service"
208
- loadKey(alias)?.let { return it }
209
- return generateAesKey(alias, strongBox = true) ?: generateAesKey(alias, strongBox = false)!!
210
- }
211
-
212
- private fun generateAesKey(alias: String, strongBox: Boolean): SecretKey? = try {
213
- val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
214
- val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
215
- .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
216
- .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
217
- .setKeySize(256)
218
- if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)
219
- kg.init(builder.build())
220
- kg.generateKey()
221
- } catch (e: Exception) {
222
- if (strongBox) null else throw e
223
- }
224
-
225
- private fun hmacKey(service: String): SecretKey {
226
- val alias = "okint_enchmac_$service"
227
- loadKey(alias)?.let { return it }
228
- return generateHmacKey(alias, strongBox = true) ?: generateHmacKey(alias, strongBox = false)!!
229
- }
230
-
231
- private fun generateHmacKey(alias: String, strongBox: Boolean): SecretKey? = try {
232
- val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE)
233
- val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
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 token(service: String, key: String): String {
242
- val mac = Mac.getInstance("HmacSHA256")
243
- mac.init(hmacKey(service))
244
- return Base64.encodeToString(mac.doFinal(key.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
245
- }
246
-
247
- private fun encrypt(service: String, plaintext: String): String {
248
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
249
- cipher.init(Cipher.ENCRYPT_MODE, aesKey(service))
250
- val iv = cipher.iv
251
- val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
252
- val out = ByteArray(1 + iv.size + ct.size)
253
- out[0] = iv.size.toByte()
254
- System.arraycopy(iv, 0, out, 1, iv.size)
255
- System.arraycopy(ct, 0, out, 1 + iv.size, ct.size)
256
- return Base64.encodeToString(out, Base64.NO_WRAP)
257
- }
258
-
259
- private fun decrypt(service: String, b64: String): String {
260
- val data = Base64.decode(b64, Base64.NO_WRAP)
261
- val ivLen = data[0].toInt() and 0xFF
262
- val iv = data.copyOfRange(1, 1 + ivLen)
263
- val ct = data.copyOfRange(1 + ivLen, data.size)
264
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
265
- cipher.init(Cipher.DECRYPT_MODE, aesKey(service), GCMParameterSpec(128, iv))
266
- return String(cipher.doFinal(ct), Charsets.UTF_8)
267
- }
268
-
269
- /** Decrypt that tolerates a lost/rotated key (restored backup) — returns null, never crashes. */
270
- private fun decryptOrNull(service: String, b64: String): String? = try {
271
- decrypt(service, b64)
272
- } catch (e: Exception) {
273
- null
274
- }
275
-
276
- // ── secure + requireAuth (biometric-gated AES key, per-operation CryptoObject)
277
- //
278
- // Opt-in path (createStorage({ backend:'secure', requireAuth:true })). The AES
279
- // key is `setUserAuthenticationRequired`, so every encrypt/decrypt must run
280
- // through a framework BiometricPrompt bound to the operation's Cipher. Uses a
281
- // distinct key alias so it never collides with the non-gated secure key;
282
- // ciphertext lives in the same SharedPreferences file, so remove/clear/keys
283
- // work unchanged. Strong biometric only (CryptoObject can't combine with
284
- // device-credential); API 28+. NOTE: the BiometricPrompt UI cannot be
285
- // exercised without a real device — this path is build-verified on-device.
286
-
287
- private fun secureAuthAesKey(service: String): SecretKey {
288
- val alias = "okint_secauth_$service"
289
- loadKey(alias)?.let { return it }
290
- return generateAuthAesKey(alias, strongBox = true) ?: generateAuthAesKey(alias, strongBox = false)!!
291
- }
292
-
293
- private fun generateAuthAesKey(alias: String, strongBox: Boolean): SecretKey? = try {
294
- val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
295
- val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
296
- .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
297
- .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
298
- .setKeySize(256)
299
- .setUserAuthenticationRequired(true)
300
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
301
- // Timeout 0 every use requires a fresh auth via CryptoObject.
302
- builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
303
- }
304
- if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setIsStrongBoxBacked(true)
305
- kg.init(builder.build())
306
- kg.generateKey()
307
- } catch (e: Exception) {
308
- if (strongBox) null else throw e
309
- }
310
-
311
- private fun secureSetAuth(service: String, key: String, value: String, promise: Promise) {
312
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
313
- promise.reject("E_OKINT_AUTH", "requireAuth needs Android 9 (API 28)+")
314
- return
315
- }
316
- try {
317
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
318
- cipher.init(Cipher.ENCRYPT_MODE, secureAuthAesKey(service))
319
- authenticate(cipher, "Authenticate to save", promise) { authed ->
320
- val iv = authed.iv
321
- val ct = authed.doFinal(value.toByteArray(Charsets.UTF_8))
322
- val out = ByteArray(1 + iv.size + ct.size)
323
- out[0] = iv.size.toByte()
324
- System.arraycopy(iv, 0, out, 1, iv.size)
325
- System.arraycopy(ct, 0, out, 1 + iv.size, ct.size)
326
- securePrefs(service).edit().putString(key, Base64.encodeToString(out, Base64.NO_WRAP)).commit()
327
- null
328
- }
329
- } catch (e: Exception) {
330
- promise.reject("E_OKINT_SET", e.message, e)
331
- }
332
- }
333
-
334
- private fun secureGetAuth(service: String, key: String, promise: Promise) {
335
- val raw = securePrefs(service).getString(key, null)
336
- if (raw == null) {
337
- promise.resolve(null)
338
- return
339
- }
340
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
341
- promise.reject("E_OKINT_AUTH", "requireAuth needs Android 9 (API 28)+")
342
- return
343
- }
344
- try {
345
- val data = Base64.decode(raw, Base64.NO_WRAP)
346
- val ivLen = data[0].toInt() and 0xFF
347
- val iv = data.copyOfRange(1, 1 + ivLen)
348
- val ct = data.copyOfRange(1 + ivLen, data.size)
349
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
350
- cipher.init(Cipher.DECRYPT_MODE, secureAuthAesKey(service), GCMParameterSpec(128, iv))
351
- authenticate(cipher, "Authenticate to access", promise) { authed ->
352
- String(authed.doFinal(ct), Charsets.UTF_8)
353
- }
354
- } catch (e: Exception) {
355
- promise.reject("E_OKINT_GET", e.message, e)
356
- }
357
- }
358
-
359
- /**
360
- * Present a framework BiometricPrompt bound to [cipher]; on success run
361
- * [onAuthed] with the authenticated cipher and resolve [promise] with its
362
- * result. Runs on the UI thread (BiometricPrompt requirement).
363
- */
364
- @androidx.annotation.RequiresApi(Build.VERSION_CODES.P)
365
- private fun authenticate(cipher: Cipher, title: String, promise: Promise, onAuthed: (Cipher) -> String?) {
366
- val activity = currentActivity
367
- if (activity == null) {
368
- promise.reject("E_OKINT_AUTH", "No foreground Activity to present the authentication prompt")
369
- return
370
- }
371
- activity.runOnUiThread {
372
- try {
373
- val executor = activity.mainExecutor
374
- val builder = android.hardware.biometrics.BiometricPrompt.Builder(activity).setTitle(title)
375
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
376
- builder.setAllowedAuthenticators(android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG)
377
- } else {
378
- builder.setNegativeButton("Cancel", executor) { _, _ ->
379
- promise.reject("E_OKINT_AUTH_CANCELLED", "Authentication cancelled")
380
- }
381
- }
382
- val prompt = builder.build()
383
- val crypto = android.hardware.biometrics.BiometricPrompt.CryptoObject(cipher)
384
- prompt.authenticate(
385
- crypto,
386
- android.os.CancellationSignal(),
387
- executor,
388
- object : android.hardware.biometrics.BiometricPrompt.AuthenticationCallback() {
389
- override fun onAuthenticationSucceeded(result: android.hardware.biometrics.BiometricPrompt.AuthenticationResult) {
390
- try {
391
- val authedCipher = result.cryptoObject?.cipher ?: cipher
392
- promise.resolve(onAuthed(authedCipher))
393
- } catch (e: Exception) {
394
- promise.reject("E_OKINT_AUTH_CRYPTO", e.message, e)
395
- }
396
- }
397
-
398
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
399
- promise.reject("E_OKINT_AUTH", errString.toString())
400
- }
401
- },
402
- )
403
- } catch (e: Exception) {
404
- promise.reject("E_OKINT_AUTH", e.message, e)
405
- }
406
- }
407
- }
408
-
409
- // ── encrypted store (enc_ table: HMAC token + encrypted key + encrypted value)
410
-
411
- private fun encTable(service: String): String = "enc_" + service.replace(Regex("[^A-Za-z0-9_]"), "_")
412
-
413
- private fun ensureEnc(db: SQLiteDatabase, service: String) {
414
- db.execSQL("CREATE TABLE IF NOT EXISTS ${encTable(service)} (kt TEXT PRIMARY KEY, ke TEXT NOT NULL, ve TEXT NOT NULL)")
415
- }
416
-
417
- @Synchronized
418
- private fun encSet(service: String, key: String, value: String) {
419
- val db = dbHelper.writableDatabase
420
- ensureEnc(db, service)
421
- db.execSQL(
422
- "INSERT OR REPLACE INTO ${encTable(service)} (kt, ke, ve) VALUES (?, ?, ?)",
423
- arrayOf(token(service, key), encrypt(service, key), encrypt(service, value)),
424
- )
425
- }
426
-
427
- @Synchronized
428
- private fun encGet(service: String, key: String): String? {
429
- val db = dbHelper.readableDatabase
430
- ensureEnc(db, service)
431
- db.rawQuery("SELECT ve FROM ${encTable(service)} WHERE kt = ?", arrayOf(token(service, key))).use { c ->
432
- return if (c.moveToFirst()) decryptOrNull(service, c.getString(0)) else null
433
- }
434
- }
435
-
436
- @Synchronized
437
- private fun encExec(service: String, sql: String, args: Array<String>) {
438
- val db = dbHelper.writableDatabase
439
- ensureEnc(db, service)
440
- if (args.isEmpty()) db.execSQL(sql) else db.execSQL(sql, args)
441
- }
442
-
443
- @Synchronized
444
- private fun encKeys(service: String): List<String> {
445
- val db = dbHelper.readableDatabase
446
- ensureEnc(db, service)
447
- val keys = ArrayList<String>()
448
- db.rawQuery("SELECT ke FROM ${encTable(service)}", emptyArray()).use { c ->
449
- while (c.moveToNext()) decryptOrNull(service, c.getString(0))?.let { keys.add(it) }
450
- }
451
- return keys
452
- }
453
-
454
- // ── sqlite store (plaintext key/value table) ──────────────────────────────────
455
-
456
- private val dbHelper: SQLiteOpenHelper by lazy {
457
- object : SQLiteOpenHelper(reactContext, "okint_sqlite.db", null, 1) {
458
- override fun onCreate(db: SQLiteDatabase) {}
459
- override fun onUpgrade(db: SQLiteDatabase, oldV: Int, newV: Int) {}
460
- }
461
- }
462
-
463
- private fun kvTable(service: String): String = "kv_" + service.replace(Regex("[^A-Za-z0-9_]"), "_")
464
-
465
- private fun ensureKv(db: SQLiteDatabase, service: String) {
466
- db.execSQL("CREATE TABLE IF NOT EXISTS ${kvTable(service)} (k TEXT PRIMARY KEY, v TEXT NOT NULL)")
467
- }
468
-
469
- @Synchronized
470
- private fun sqliteSet(service: String, key: String, value: String) {
471
- val db = dbHelper.writableDatabase
472
- ensureKv(db, service)
473
- db.execSQL("INSERT OR REPLACE INTO ${kvTable(service)} (k, v) VALUES (?, ?)", arrayOf(key, value))
474
- }
475
-
476
- @Synchronized
477
- private fun sqliteGet(service: String, key: String): String? {
478
- val db = dbHelper.readableDatabase
479
- ensureKv(db, service)
480
- db.rawQuery("SELECT v FROM ${kvTable(service)} WHERE k = ?", arrayOf(key)).use { c ->
481
- return if (c.moveToFirst()) c.getString(0) else null
482
- }
483
- }
484
-
485
- @Synchronized
486
- private fun sqliteExec(service: String, sql: String, args: Array<String>) {
487
- val db = dbHelper.writableDatabase
488
- ensureKv(db, service)
489
- if (args.isEmpty()) db.execSQL(sql) else db.execSQL(sql, args)
490
- }
491
-
492
- @Synchronized
493
- private fun sqliteKeys(service: String): List<String> {
494
- val db = dbHelper.readableDatabase
495
- ensureKv(db, service)
496
- val keys = ArrayList<String>()
497
- db.rawQuery("SELECT k FROM ${kvTable(service)}", emptyArray()).use { c ->
498
- while (c.moveToNext()) keys.add(c.getString(0))
499
- }
500
- return keys
501
- }
502
-
503
- companion object {
504
- init {
505
- try {
506
- System.loadLibrary("okint")
507
- } catch (ignored: Throwable) {
508
- // JSI engine optional — installJSI() returns false if the lib is absent.
509
- }
510
- }
511
-
512
- const val NAME = "OkintRnStorage"
513
- private const val ANDROID_KEYSTORE = "AndroidKeyStore"
514
- private const val STORE_SECURE = "secure"
515
- private const val STORE_ASYNC = "async"
516
- private const val STORE_ENCRYPTED = "encrypted"
517
- private const val STORE_SQLITE = "sqlite"
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
+ }