@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 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 (hardware-backed where the
185
- device offers it); ciphertext is held in plain SharedPreferences. This is the
186
- same construction `EncryptedSharedPreferences` used internally without the
187
- now-deprecated `androidx.security:security-crypto`, and with **no third-party
188
- dependency** (Tink, DataStore, etc.). A failed decrypt (restored backup,
189
- invalidated key) returns `null` rather than crashing on launch.
190
+ 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 aesKey(service: String): SecretKey {
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
- (ks.getKey(alias, null) as? SecretKey)?.let { return it }
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
- kg.init(
190
- KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
191
- .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
192
- .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
193
- .setKeySize(256)
194
- .build(),
195
- )
196
- return kg.generateKey()
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
- val ks = KeyStore.getInstance(ANDROID_KEYSTORE)
202
- ks.load(null)
203
- (ks.getKey(alias, null) as? SecretKey)?.let { return it }
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
- kg.init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN).build())
206
- return kg.generateKey()
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_]"), "_")
@@ -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 = OkintKCGetData(OkintScope(@"secure", service), key);
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
- OSStatus s = OkintKCSetData(OkintScope(@"secure", service), key, [value dataUsingEncoding:NSUTF8StringEncoding]);
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
- constructor(native: NativeOkintStorage, service: string, kind: NativeStoreKind);
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
- constructor(native, service, kind) {
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.6.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[]>;