@okint-digital/okint-rn-storage 0.6.0 → 0.7.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 +21 -6
- package/android/src/main/AndroidManifest.xml +4 -1
- package/android/src/main/java/com/okint/rnstorage/OkintRnStorageModule.kt +183 -18
- package/ios/OkintRnStorage.m +48 -4
- package/lib/backends/native-backend.d.ts +5 -1
- package/lib/backends/native-backend.js +7 -3
- package/lib/index.js +4 -2
- package/lib/types.d.ts +19 -2
- package/package.json +1 -1
- package/src/backends/native-backend.ts +4 -2
- package/src/index.ts +4 -2
- package/src/types.ts +19 -2
package/README.md
CHANGED
|
@@ -41,6 +41,12 @@ await auth.setString('refreshToken', token);
|
|
|
41
41
|
const token = await auth.getString('refreshToken');
|
|
42
42
|
await auth.setItem('fcm', { token: t, platform: 'android' }); // JSON helper
|
|
43
43
|
|
|
44
|
+
// High-value secrets → require Face ID / fingerprint / passcode to access.
|
|
45
|
+
// Opt in per use case; the OS shows the auth prompt on read.
|
|
46
|
+
const wallet = createStorage({ backend: 'secure', namespace: 'wallet', requireAuth: true });
|
|
47
|
+
await wallet.setString('privateKey', pk);
|
|
48
|
+
const pk = await wallet.getString('privateKey'); // ← triggers the biometric prompt
|
|
49
|
+
|
|
44
50
|
// Plain persistent data → SharedPreferences / UserDefaults
|
|
45
51
|
const prefs = createStorage({ backend: 'async', namespace: 'prefs' });
|
|
46
52
|
await prefs.setBoolean('onboarded', true);
|
|
@@ -181,12 +187,21 @@ catch (e) { if (e instanceof OkintStorageError && e.code === 'PARSE_ERROR') { /*
|
|
|
181
187
|
## Security & reliability
|
|
182
188
|
|
|
183
189
|
- **Android `secure`** encrypts every value with **AES-256-GCM** under a
|
|
184
|
-
per-namespace, non-exportable **AndroidKeystore** key
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
+
per-namespace, non-exportable **AndroidKeystore** key, preferring the dedicated
|
|
191
|
+
**StrongBox** secure element (Titan M / SE) and falling back to the TEE; ciphertext
|
|
192
|
+
is held in plain SharedPreferences. This is the same construction
|
|
193
|
+
`EncryptedSharedPreferences` used internally — without the now-deprecated
|
|
194
|
+
`androidx.security:security-crypto`, and with **no third-party dependency** (Tink,
|
|
195
|
+
DataStore, etc.). A failed decrypt (restored backup, invalidated key) returns
|
|
196
|
+
`null` rather than crashing on launch.
|
|
197
|
+
- **Biometric / device-credential gating (`requireAuth`)** — opt-in per secure
|
|
198
|
+
store. iOS binds the Keychain item to the **Secure Enclave** via `SecAccessControl`
|
|
199
|
+
(`.userPresence` — Face ID / Touch ID *or* passcode); the OS prompts automatically
|
|
200
|
+
on read. Android (API 28+) marks the AES key `setUserAuthenticationRequired` and
|
|
201
|
+
routes every read/write through a framework **`BiometricPrompt`** bound to the
|
|
202
|
+
operation's `Cipher` (strong biometric; per-operation). With no enrolled
|
|
203
|
+
authenticator, or on API < 28, gated calls reject rather than silently
|
|
204
|
+
downgrading. Off by default — nothing prompts unless you ask for it.
|
|
190
205
|
- **iOS `secure`** uses the Keychain with
|
|
191
206
|
`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (not iCloud-synced, not in
|
|
192
207
|
encrypted backups, available to background tasks after first unlock) + the
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
<!-- Only used by the opt-in `requireAuth` secure store (BiometricPrompt). -->
|
|
3
|
+
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
|
4
|
+
</manifest>
|
|
@@ -4,6 +4,7 @@ import android.content.Context
|
|
|
4
4
|
import android.content.SharedPreferences
|
|
5
5
|
import android.database.sqlite.SQLiteDatabase
|
|
6
6
|
import android.database.sqlite.SQLiteOpenHelper
|
|
7
|
+
import android.os.Build
|
|
7
8
|
import android.security.keystore.KeyGenParameterSpec
|
|
8
9
|
import android.security.keystore.KeyProperties
|
|
9
10
|
import android.util.Base64
|
|
@@ -54,7 +55,11 @@ class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
|
|
|
54
55
|
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
55
56
|
|
|
56
57
|
@ReactMethod
|
|
57
|
-
fun setItem(service: String, key: String, value: String, store: String, promise: Promise) {
|
|
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
|
+
}
|
|
58
63
|
try {
|
|
59
64
|
when (store) {
|
|
60
65
|
STORE_SECURE -> securePrefs(service).edit().putString(key, encrypt(service, value)).commit()
|
|
@@ -69,7 +74,11 @@ class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
|
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
@ReactMethod
|
|
72
|
-
fun getItem(service: String, key: String, store: String, promise: Promise) {
|
|
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
|
+
}
|
|
73
82
|
try {
|
|
74
83
|
promise.resolve(readOne(service, key, store))
|
|
75
84
|
} catch (e: Exception) {
|
|
@@ -180,30 +189,53 @@ class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
|
|
|
180
189
|
|
|
181
190
|
// ── Crypto core (AES-256-GCM + HMAC token, rooted in AndroidKeystore) ────────
|
|
182
191
|
|
|
183
|
-
private fun
|
|
184
|
-
val alias = "okint_enckey_$service"
|
|
192
|
+
private fun loadKey(alias: String): SecretKey? {
|
|
185
193
|
val ks = KeyStore.getInstance(ANDROID_KEYSTORE)
|
|
186
194
|
ks.load(null)
|
|
187
|
-
|
|
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 {
|
|
188
213
|
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
|
|
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
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
private fun hmacKey(service: String): SecretKey {
|
|
200
226
|
val alias = "okint_enchmac_$service"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 {
|
|
204
232
|
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE)
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
207
239
|
}
|
|
208
240
|
|
|
209
241
|
private fun token(service: String, key: String): String {
|
|
@@ -241,6 +273,139 @@ class OkintRnStorageModule(private val reactContext: ReactApplicationContext) :
|
|
|
241
273
|
null
|
|
242
274
|
}
|
|
243
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
|
+
|
|
244
409
|
// ── encrypted store (enc_ table: HMAC token + encrypted key + encrypted value)
|
|
245
410
|
|
|
246
411
|
private fun encTable(service: String): String = "enc_" + service.replace(Regex("[^A-Za-z0-9_]"), "_")
|
package/ios/OkintRnStorage.m
CHANGED
|
@@ -75,6 +75,26 @@ static OSStatus OkintKCSetData(NSString *scope, NSString *key, NSData *data) {
|
|
|
75
75
|
return s;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Store a secret behind device-credential auth: the item is bound to the Secure
|
|
80
|
+
* Enclave via SecAccessControl (`.userPresence` — Face ID / Touch ID OR device
|
|
81
|
+
* passcode). The OS prompts automatically on READ; writing doesn't prompt. We
|
|
82
|
+
* delete-then-add so overwriting an existing gated item never triggers a prompt.
|
|
83
|
+
*/
|
|
84
|
+
static OSStatus OkintKCSetDataAuth(NSString *scope, NSString *key, NSData *data) {
|
|
85
|
+
SecAccessControlRef ac = SecAccessControlCreateWithFlags(
|
|
86
|
+
kCFAllocatorDefault,
|
|
87
|
+
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
88
|
+
kSecAccessControlUserPresence,
|
|
89
|
+
NULL);
|
|
90
|
+
if (ac == NULL) return errSecParam;
|
|
91
|
+
SecItemDelete((__bridge CFDictionaryRef)OkintKCQuery(scope, key));
|
|
92
|
+
NSMutableDictionary *add = OkintKCQuery(scope, key);
|
|
93
|
+
add[(__bridge id)kSecValueData] = data;
|
|
94
|
+
add[(__bridge id)kSecAttrAccessControl] = (__bridge_transfer id)ac; // supersedes kSecAttrAccessible
|
|
95
|
+
return SecItemAdd((__bridge CFDictionaryRef)add, NULL);
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
static NSData *OkintKCGetData(NSString *scope, NSString *key) {
|
|
79
99
|
NSMutableDictionary *q = OkintKCQuery(scope, key);
|
|
80
100
|
q[(__bridge id)kSecReturnData] = @YES;
|
|
@@ -86,6 +106,23 @@ static NSData *OkintKCGetData(NSString *scope, NSString *key) {
|
|
|
86
106
|
return nil;
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Read a gated secret. If the item carries an access-control (written via
|
|
111
|
+
* OkintKCSetDataAuth), the Keychain shows the auth UI automatically; `prompt`
|
|
112
|
+
* sets the reason string. On a plain item this behaves like OkintKCGetData.
|
|
113
|
+
*/
|
|
114
|
+
static NSData *OkintKCGetDataAuth(NSString *scope, NSString *key, NSString *prompt) {
|
|
115
|
+
NSMutableDictionary *q = OkintKCQuery(scope, key);
|
|
116
|
+
q[(__bridge id)kSecReturnData] = @YES;
|
|
117
|
+
q[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
|
|
118
|
+
if (prompt) q[(__bridge id)kSecUseOperationPrompt] = prompt;
|
|
119
|
+
CFTypeRef result = NULL;
|
|
120
|
+
if (SecItemCopyMatching((__bridge CFDictionaryRef)q, &result) == errSecSuccess) {
|
|
121
|
+
return (__bridge_transfer NSData *)result;
|
|
122
|
+
}
|
|
123
|
+
return nil;
|
|
124
|
+
}
|
|
125
|
+
|
|
89
126
|
#pragma mark - Crypto (encrypted store)
|
|
90
127
|
|
|
91
128
|
static NSData *OkintRandom(size_t n) {
|
|
@@ -345,9 +382,11 @@ static NSDictionary *OkintEncAll(NSString *service) {
|
|
|
345
382
|
|
|
346
383
|
#pragma mark - Read dispatch
|
|
347
384
|
|
|
348
|
-
static NSString *OkintReadOne(NSString *service, NSString *key, NSString *store) {
|
|
385
|
+
static NSString *OkintReadOne(NSString *service, NSString *key, NSString *store, BOOL requireAuth) {
|
|
349
386
|
if ([store isEqualToString:@"secure"]) {
|
|
350
|
-
NSData *d =
|
|
387
|
+
NSData *d = requireAuth
|
|
388
|
+
? OkintKCGetDataAuth(OkintScope(@"secure", service), key, @"Authenticate to access your saved data")
|
|
389
|
+
: OkintKCGetData(OkintScope(@"secure", service), key);
|
|
351
390
|
return d ? [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] : nil;
|
|
352
391
|
}
|
|
353
392
|
if ([store isEqualToString:@"encrypted"]) return OkintEncGet(service, key);
|
|
@@ -358,9 +397,13 @@ static NSString *OkintReadOne(NSString *service, NSString *key, NSString *store)
|
|
|
358
397
|
#pragma mark - Methods
|
|
359
398
|
|
|
360
399
|
RCT_EXPORT_METHOD(setItem:(NSString *)service key:(NSString *)key value:(NSString *)value store:(NSString *)store
|
|
400
|
+
requireAuth:(BOOL)requireAuth
|
|
361
401
|
resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
|
|
362
402
|
if ([store isEqualToString:@"secure"]) {
|
|
363
|
-
|
|
403
|
+
NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding];
|
|
404
|
+
OSStatus s = requireAuth
|
|
405
|
+
? OkintKCSetDataAuth(OkintScope(@"secure", service), key, data)
|
|
406
|
+
: OkintKCSetData(OkintScope(@"secure", service), key, data);
|
|
364
407
|
if (s == errSecSuccess) resolve([NSNull null]);
|
|
365
408
|
else reject(@"E_OKINT_SET", [NSString stringWithFormat:@"Keychain set failed (%d)", (int)s], nil);
|
|
366
409
|
return;
|
|
@@ -380,8 +423,9 @@ RCT_EXPORT_METHOD(setItem:(NSString *)service key:(NSString *)key value:(NSStrin
|
|
|
380
423
|
}
|
|
381
424
|
|
|
382
425
|
RCT_EXPORT_METHOD(getItem:(NSString *)service key:(NSString *)key store:(NSString *)store
|
|
426
|
+
requireAuth:(BOOL)requireAuth
|
|
383
427
|
resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
|
|
384
|
-
NSString *v = OkintReadOne(service, key, store);
|
|
428
|
+
NSString *v = OkintReadOne(service, key, store, requireAuth);
|
|
385
429
|
resolve(v ?: [NSNull null]);
|
|
386
430
|
}
|
|
387
431
|
|
|
@@ -15,7 +15,11 @@ export declare class NativeBackend implements StorageBackend {
|
|
|
15
15
|
private readonly native;
|
|
16
16
|
private readonly service;
|
|
17
17
|
readonly kind: NativeStoreKind;
|
|
18
|
-
|
|
18
|
+
/** Gate reads/writes behind device-credential auth (secure backend only). */
|
|
19
|
+
private readonly requireAuth;
|
|
20
|
+
constructor(native: NativeOkintStorage, service: string, kind: NativeStoreKind,
|
|
21
|
+
/** Gate reads/writes behind device-credential auth (secure backend only). */
|
|
22
|
+
requireAuth?: boolean);
|
|
19
23
|
getString(key: string): Promise<string | null>;
|
|
20
24
|
setString(key: string, value: string): Promise<void>;
|
|
21
25
|
remove(key: string): Promise<void>;
|
|
@@ -18,14 +18,18 @@ class NativeBackend {
|
|
|
18
18
|
native;
|
|
19
19
|
service;
|
|
20
20
|
kind;
|
|
21
|
-
|
|
21
|
+
requireAuth;
|
|
22
|
+
constructor(native, service, kind,
|
|
23
|
+
/** Gate reads/writes behind device-credential auth (secure backend only). */
|
|
24
|
+
requireAuth = false) {
|
|
22
25
|
this.native = native;
|
|
23
26
|
this.service = service;
|
|
24
27
|
this.kind = kind;
|
|
28
|
+
this.requireAuth = requireAuth;
|
|
25
29
|
}
|
|
26
30
|
async getString(key) {
|
|
27
31
|
try {
|
|
28
|
-
const v = await this.native.getItem(this.service, key, this.kind);
|
|
32
|
+
const v = await this.native.getItem(this.service, key, this.kind, this.requireAuth);
|
|
29
33
|
return v ?? null;
|
|
30
34
|
}
|
|
31
35
|
catch (e) {
|
|
@@ -34,7 +38,7 @@ class NativeBackend {
|
|
|
34
38
|
}
|
|
35
39
|
async setString(key, value) {
|
|
36
40
|
try {
|
|
37
|
-
await this.native.setItem(this.service, key, value, this.kind);
|
|
41
|
+
await this.native.setItem(this.service, key, value, this.kind, this.requireAuth);
|
|
38
42
|
}
|
|
39
43
|
catch (e) {
|
|
40
44
|
throw wrap(e, `set "${key}"`);
|
package/lib/index.js
CHANGED
|
@@ -28,13 +28,15 @@ const DEFAULT_NAMESPACE = 'okint';
|
|
|
28
28
|
*/
|
|
29
29
|
function createStorage(options) {
|
|
30
30
|
const namespace = (0, validate_1.normalizeNamespace)(options.namespace, DEFAULT_NAMESPACE);
|
|
31
|
-
return new facade_1.StorageFacade(resolveBackend(options.backend, namespace));
|
|
31
|
+
return new facade_1.StorageFacade(resolveBackend(options.backend, namespace, options.requireAuth === true));
|
|
32
32
|
}
|
|
33
|
-
function resolveBackend(kind, namespace) {
|
|
33
|
+
function resolveBackend(kind, namespace, requireAuth) {
|
|
34
34
|
switch (kind) {
|
|
35
35
|
case 'memory':
|
|
36
36
|
return new memory_1.MemoryBackend('memory');
|
|
37
37
|
case 'secure':
|
|
38
|
+
// `requireAuth` only gates the secure store (hardware-key crypto).
|
|
39
|
+
return new native_backend_1.NativeBackend((0, bridge_1.getNativeModule)(), namespace, kind, requireAuth);
|
|
38
40
|
case 'async':
|
|
39
41
|
case 'encrypted':
|
|
40
42
|
case 'sqlite':
|
package/lib/types.d.ts
CHANGED
|
@@ -20,6 +20,23 @@ export interface OkintStorageOptions {
|
|
|
20
20
|
* Defaults to `'okint'`.
|
|
21
21
|
*/
|
|
22
22
|
namespace?: string;
|
|
23
|
+
/**
|
|
24
|
+
* **`secure` backend only.** Require device-credential / biometric
|
|
25
|
+
* authentication (Face ID, fingerprint, or device passcode) to access a
|
|
26
|
+
* secret. Off by default — set it per use case (e.g. gate a payment token but
|
|
27
|
+
* not a UI preference).
|
|
28
|
+
*
|
|
29
|
+
* - **iOS:** the Keychain item is bound to the Secure Enclave via
|
|
30
|
+
* `SecAccessControl` (`.userPresence` — biometry *or* passcode). The OS
|
|
31
|
+
* shows the auth prompt automatically on read; writes don't prompt.
|
|
32
|
+
* - **Android (API 28+):** the AES key is `setUserAuthenticationRequired`, so
|
|
33
|
+
* reads *and* writes present a `BiometricPrompt` bound to the operation's
|
|
34
|
+
* `Cipher`. On API < 28, or with no biometric/credential enrolled, calls
|
|
35
|
+
* reject with `NATIVE_ERROR` rather than silently downgrading.
|
|
36
|
+
*
|
|
37
|
+
* Ignored by non-`secure` backends.
|
|
38
|
+
*/
|
|
39
|
+
requireAuth?: boolean;
|
|
23
40
|
}
|
|
24
41
|
/**
|
|
25
42
|
* Low-level backend contract. Everything is async (the secure/native backends
|
|
@@ -108,8 +125,8 @@ export interface SyncPersistence {
|
|
|
108
125
|
* inject a fake implementation. `store` selects which native store to target.
|
|
109
126
|
*/
|
|
110
127
|
export interface NativeOkintStorage {
|
|
111
|
-
setItem(service: string, key: string, value: string, store: NativeStoreKind): Promise<void>;
|
|
112
|
-
getItem(service: string, key: string, store: NativeStoreKind): Promise<string | null>;
|
|
128
|
+
setItem(service: string, key: string, value: string, store: NativeStoreKind, requireAuth: boolean): Promise<void>;
|
|
129
|
+
getItem(service: string, key: string, store: NativeStoreKind, requireAuth: boolean): Promise<string | null>;
|
|
113
130
|
removeItem(service: string, key: string, store: NativeStoreKind): Promise<void>;
|
|
114
131
|
clear(service: string, store: NativeStoreKind): Promise<void>;
|
|
115
132
|
getAllKeys(service: string, store: NativeStoreKind): Promise<string[]>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@okint-digital/okint-rn-storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Vanilla, pluggable React Native storage — one API over swappable backends: hardware Keystore/Keychain (secure), AES-encrypted blobs (encrypted), SQLite, SharedPreferences/UserDefaults (async), a synchronous fast store, or in-memory.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -18,11 +18,13 @@ export class NativeBackend implements StorageBackend {
|
|
|
18
18
|
private readonly native: NativeOkintStorage,
|
|
19
19
|
private readonly service: string,
|
|
20
20
|
readonly kind: NativeStoreKind,
|
|
21
|
+
/** Gate reads/writes behind device-credential auth (secure backend only). */
|
|
22
|
+
private readonly requireAuth: boolean = false,
|
|
21
23
|
) {}
|
|
22
24
|
|
|
23
25
|
async getString(key: string): Promise<string | null> {
|
|
24
26
|
try {
|
|
25
|
-
const v = await this.native.getItem(this.service, key, this.kind);
|
|
27
|
+
const v = await this.native.getItem(this.service, key, this.kind, this.requireAuth);
|
|
26
28
|
return v ?? null;
|
|
27
29
|
} catch (e) {
|
|
28
30
|
throw wrap(e, `get "${key}"`);
|
|
@@ -31,7 +33,7 @@ export class NativeBackend implements StorageBackend {
|
|
|
31
33
|
|
|
32
34
|
async setString(key: string, value: string): Promise<void> {
|
|
33
35
|
try {
|
|
34
|
-
await this.native.setItem(this.service, key, value, this.kind);
|
|
36
|
+
await this.native.setItem(this.service, key, value, this.kind, this.requireAuth);
|
|
35
37
|
} catch (e) {
|
|
36
38
|
throw wrap(e, `set "${key}"`);
|
|
37
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -32,14 +32,16 @@ const DEFAULT_NAMESPACE = 'okint';
|
|
|
32
32
|
*/
|
|
33
33
|
export function createStorage(options: OkintStorageOptions): OkintStorage {
|
|
34
34
|
const namespace = normalizeNamespace(options.namespace, DEFAULT_NAMESPACE);
|
|
35
|
-
return new StorageFacade(resolveBackend(options.backend, namespace));
|
|
35
|
+
return new StorageFacade(resolveBackend(options.backend, namespace, options.requireAuth === true));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function resolveBackend(kind: BackendKind, namespace: string): StorageBackend {
|
|
38
|
+
function resolveBackend(kind: BackendKind, namespace: string, requireAuth: boolean): StorageBackend {
|
|
39
39
|
switch (kind) {
|
|
40
40
|
case 'memory':
|
|
41
41
|
return new MemoryBackend('memory');
|
|
42
42
|
case 'secure':
|
|
43
|
+
// `requireAuth` only gates the secure store (hardware-key crypto).
|
|
44
|
+
return new NativeBackend(getNativeModule(), namespace, kind, requireAuth);
|
|
43
45
|
case 'async':
|
|
44
46
|
case 'encrypted':
|
|
45
47
|
case 'sqlite':
|
package/src/types.ts
CHANGED
|
@@ -23,6 +23,23 @@ export interface OkintStorageOptions {
|
|
|
23
23
|
* Defaults to `'okint'`.
|
|
24
24
|
*/
|
|
25
25
|
namespace?: string;
|
|
26
|
+
/**
|
|
27
|
+
* **`secure` backend only.** Require device-credential / biometric
|
|
28
|
+
* authentication (Face ID, fingerprint, or device passcode) to access a
|
|
29
|
+
* secret. Off by default — set it per use case (e.g. gate a payment token but
|
|
30
|
+
* not a UI preference).
|
|
31
|
+
*
|
|
32
|
+
* - **iOS:** the Keychain item is bound to the Secure Enclave via
|
|
33
|
+
* `SecAccessControl` (`.userPresence` — biometry *or* passcode). The OS
|
|
34
|
+
* shows the auth prompt automatically on read; writes don't prompt.
|
|
35
|
+
* - **Android (API 28+):** the AES key is `setUserAuthenticationRequired`, so
|
|
36
|
+
* reads *and* writes present a `BiometricPrompt` bound to the operation's
|
|
37
|
+
* `Cipher`. On API < 28, or with no biometric/credential enrolled, calls
|
|
38
|
+
* reject with `NATIVE_ERROR` rather than silently downgrading.
|
|
39
|
+
*
|
|
40
|
+
* Ignored by non-`secure` backends.
|
|
41
|
+
*/
|
|
42
|
+
requireAuth?: boolean;
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
/**
|
|
@@ -141,8 +158,8 @@ export interface SyncPersistence {
|
|
|
141
158
|
* inject a fake implementation. `store` selects which native store to target.
|
|
142
159
|
*/
|
|
143
160
|
export interface NativeOkintStorage {
|
|
144
|
-
setItem(service: string, key: string, value: string, store: NativeStoreKind): Promise<void>;
|
|
145
|
-
getItem(service: string, key: string, store: NativeStoreKind): Promise<string | null>;
|
|
161
|
+
setItem(service: string, key: string, value: string, store: NativeStoreKind, requireAuth: boolean): Promise<void>;
|
|
162
|
+
getItem(service: string, key: string, store: NativeStoreKind, requireAuth: boolean): Promise<string | null>;
|
|
146
163
|
removeItem(service: string, key: string, store: NativeStoreKind): Promise<void>;
|
|
147
164
|
clear(service: string, store: NativeStoreKind): Promise<void>;
|
|
148
165
|
getAllKeys(service: string, store: NativeStoreKind): Promise<string[]>;
|