diff --git a/README.md b/README.md
index 4307dae2..830f7e06 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](https://github.com/mcodex/react-native-sensitive-info)
[](LICENSE)
-Hardware-backed secure storage for React Native. Secrets are encrypted with AES-GCM, gated by biometrics or device credentials, and stored in the system Keychain (iOS/Apple) or Android Keystore — all behind a simple Promise-based API and first-class React hooks.
+Hardware-backed secure storage for React Native. Secrets are encrypted with AES-GCM, gated by biometrics or device credentials, and stored in the system Keychain (iOS) or Android Keystore behind a Promise-based API and React hooks.
> [!NOTE]
> **Upgrading from v5?** See [MIGRATION.md](./docs/MIGRATION.md). v6 requires the New Architecture (RN 0.76+) and `react-native-nitro-modules`. Windows support was removed.
@@ -98,7 +98,7 @@ console.log(item?.metadata.securityLevel) // e.g. 'secureEnclave'
await deleteItem('session-token', { service: 'auth' })
```
-Building a component? The [hooks API](#️-react-hooks-api-recommended) handles loading states, cleanup, and error boundaries — no `useEffect` boilerplate needed.
+Building a component? The [hooks API](#️-react-hooks-api-recommended) handles loading states, cleanup, and error boundaries.
## ⚛️ React Hooks API (Recommended)
@@ -177,7 +177,7 @@ import {
## 🛡️ Security
-Every secret is encrypted with **AES-GCM** and bound to a hardware-protected key — the **Secure Enclave** on Apple platforms and the **Android Keystore** (StrongBox when available). Each entry carries an **HMAC-SHA256 integrity tag** recomputed on every read; a mismatch raises `IntegrityViolationError` before any biometric prompt fires, so spoofed entries can never trigger user authentication.
+Every secret is encrypted with **AES-GCM** and bound to a hardware-protected key. Each entry carries an **HMAC-SHA256 integrity tag** recomputed on every read; a mismatch raises `IntegrityViolationError` before any biometric prompt fires.
For the full cryptographic model — key derivation, AAD binding, replay defense, and threat classification — see [THREAT_MODEL.md](./docs/THREAT_MODEL.md).
diff --git a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt
index a89b978e..234e28df 100644
--- a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt
+++ b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt
@@ -63,7 +63,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
val accessControlResolver = AccessControlResolver(securityAvailabilityResolver)
val serviceNameResolver = ServiceNameResolver(ctx)
val authenticator = BiometricAuthenticator()
- val cryptoManager = CryptoManager(authenticator)
+ val cryptoManager = CryptoManager(authenticator, ctx)
Dependencies(
context = ctx,
@@ -416,16 +416,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
}
/**
- * True when the persisted entry's Keystore key requires user authentication
- * to authorize a `Cipher.init` — i.e. biometric- or device-credential-gated
- * entries. `entry.requiresAuthentication` already covers the common case
- * (including `devicePasscode`, which `AccessControlResolver` flags as
- * auth-required), so any such entry returns `true` and is skipped by the
- * lazy refresh to avoid a second prompt. The `accessControl` fallback only
- * matters for legacy entries persisted before the flag existed: there we
- * still classify the biometry-class policies as auth-gated, while
- * `devicePasscode`/`none` legacy entries are treated as silently
- * upgradable (their keys had no auth requirement back then).
+ * True when the entry's Keystore key requires user authentication to authorize
+ * a `Cipher.init`. The `accessControl` fallback handles legacy entries
+ * persisted before `requiresAuthentication` existed.
*/
private fun requiresBiometricAuth(entry: PersistedEntry): Boolean {
if (entry.requiresAuthentication) return true
diff --git a/android/src/main/java/com/sensitiveinfo/internal/crypto/CryptoManager.kt b/android/src/main/java/com/sensitiveinfo/internal/crypto/CryptoManager.kt
index f35a3fe5..d6f5b549 100644
--- a/android/src/main/java/com/sensitiveinfo/internal/crypto/CryptoManager.kt
+++ b/android/src/main/java/com/sensitiveinfo/internal/crypto/CryptoManager.kt
@@ -1,5 +1,7 @@
package com.sensitiveinfo.internal.crypto
+import android.app.KeyguardManager
+import android.content.Context
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
@@ -30,10 +32,16 @@ private const val TRANSFORMATION = "AES/GCM/NoPadding"
* from disk.
*/
internal class CryptoManager(
- private val authenticator: BiometricAuthenticator
+ private val authenticator: BiometricAuthenticator,
+ private val context: Context
) {
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
+ private fun hasSecureLockScreen(): Boolean {
+ val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
+ return keyguard?.isDeviceLocked == true
+ }
+
/** Encrypts data and returns the ciphertext plus generated IV. */
suspend fun encrypt(
alias: String,
@@ -49,27 +57,14 @@ internal class CryptoManager(
val readyCipher = try {
if (!requiresAuth) {
- cipher.init(Cipher.ENCRYPT_MODE, key)
- cipher
+ initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
} else if (supportsKeystoreAuth) {
- try {
- cipher.init(Cipher.ENCRYPT_MODE, key)
- } catch (invalidated: KeyPermanentlyInvalidatedException) {
- deleteKey(alias)
- throw IllegalStateException("Encryption key invalidated. Item must be recreated.", invalidated)
- }
-
+ initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
val authenticated = authenticator.authenticate(prompt, resolution.allowedAuthenticators, cipher)
(authenticated ?: cipher)
} else {
authenticator.authenticate(prompt, resolution.allowedAuthenticators, null)
- try {
- cipher.init(Cipher.ENCRYPT_MODE, key)
- } catch (invalidated: KeyPermanentlyInvalidatedException) {
- deleteKey(alias)
- throw IllegalStateException("Encryption key invalidated. Item must be recreated.", invalidated)
- }
- cipher
+ initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
}
} catch (error: CancellationException) {
throw error
@@ -103,41 +98,14 @@ internal class CryptoManager(
val readyCipher = try {
if (!requiresAuth) {
- try {
- cipher.init(Cipher.DECRYPT_MODE, key, spec)
- } catch (invalidated: KeyPermanentlyInvalidatedException) {
- deleteKey(alias)
- throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
- } catch (unrecoverable: UnrecoverableKeyException) {
- deleteKey(alias)
- throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
- }
- cipher
+ initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
} else if (supportsKeystoreAuth) {
- try {
- cipher.init(Cipher.DECRYPT_MODE, key, spec)
- } catch (invalidated: KeyPermanentlyInvalidatedException) {
- deleteKey(alias)
- throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
- } catch (unrecoverable: UnrecoverableKeyException) {
- deleteKey(alias)
- throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
- }
-
+ initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
val authenticated = authenticator.authenticate(prompt, resolution.allowedAuthenticators, cipher)
(authenticated ?: cipher)
} else {
authenticator.authenticate(prompt, resolution.allowedAuthenticators, null)
- try {
- cipher.init(Cipher.DECRYPT_MODE, key, spec)
- } catch (invalidated: KeyPermanentlyInvalidatedException) {
- deleteKey(alias)
- throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
- } catch (unrecoverable: UnrecoverableKeyException) {
- deleteKey(alias)
- throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
- }
- cipher
+ initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
}
} catch (error: CancellationException) {
throw error
@@ -158,11 +126,30 @@ internal class CryptoManager(
}
}
+ private fun initCipher(
+ cipher: Cipher,
+ mode: Int,
+ key: SecretKey,
+ spec: GCMParameterSpec?,
+ alias: String
+ ): Cipher {
+ try {
+ if (spec != null) cipher.init(mode, key, spec) else cipher.init(mode, key)
+ } catch (invalidated: KeyPermanentlyInvalidatedException) {
+ deleteKey(alias)
+ val action = if (mode == Cipher.ENCRYPT_MODE) "Encryption" else "Decryption"
+ throw IllegalStateException("$action key invalidated. Item must be recreated.", invalidated)
+ } catch (unrecoverable: UnrecoverableKeyException) {
+ deleteKey(alias)
+ val action = if (mode == Cipher.ENCRYPT_MODE) "Encryption" else "Decryption"
+ throw IllegalStateException("$action key unavailable. Item must be recreated.", unrecoverable)
+ }
+ return cipher
+ }
+
/**
- * Reconstructs the resolution for data loaded from SharedPreferences.
- *
- * This lets us decrypt entries that were encrypted on a previous run without re-reading
- * the original access-control input, since the persisted metadata is authoritative.
+ * Reconstructs the resolution for data loaded from SharedPreferences,
+ * using the persisted metadata as the source of truth.
*/
fun buildResolutionForPersisted(
accessControl: AccessControl,
@@ -213,7 +200,9 @@ internal class CryptoManager(
// Defense in depth: require the device to be unlocked at the moment of use, mirroring iOS's
// `kSecAttrAccessibleWhenUnlocked` default. Available on API 28+.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // Skip when no secure screen lock exists — setUnlockedDeviceRequired crashes on Android 12-14
+ // without one (https://issuetracker.google.com/issues/191391068, fixed in Android 15).
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && hasSecureLockScreen()) {
try {
builder.setUnlockedDeviceRequired(true)
} catch (_: Throwable) {
@@ -249,7 +238,7 @@ internal class CryptoManager(
}
if (resolution.accessControl == AccessControl.DEVICEPASSCODE) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && hasSecureLockScreen()) {
builder.setUnlockedDeviceRequired(true)
}
}
diff --git a/biome.json b/biome.json
index 5ec0182f..5fc51742 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
@@ -28,7 +28,7 @@
"linter": {
"enabled": true,
"rules": {
- "recommended": true,
+ "preset": "recommended",
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error",
@@ -37,10 +37,18 @@
},
"style": {
"useImportType": "error",
- "useConsistentArrayType": "error"
+ "useConsistentArrayType": "error",
+ "useErrorCause": "warn",
+ "useConsistentEnumValueType": "warn"
},
"suspicious": {
- "noExplicitAny": "warn"
+ "noExplicitAny": "warn",
+ "noShadow": "warn",
+ "noUnnecessaryConditions": "warn",
+ "noNestedPromises": "error"
+ },
+ "security": {
+ "noScriptUrl": "error"
}
}
},
@@ -64,6 +72,14 @@
"suspicious": { "noExplicitAny": "off" }
}
}
+ },
+ {
+ "includes": ["src/hooks/**"],
+ "linter": {
+ "rules": {
+ "suspicious": { "noUnnecessaryConditions": "off" }
+ }
+ }
}
]
}
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
index 91c9b1d9..494ef55b 100644
--- a/docs/HOOKS.md
+++ b/docs/HOOKS.md
@@ -1,40 +1,21 @@
# React Hooks for react-native-sensitive-info
-This document covers the React hooks API for `react-native-sensitive-info`, designed with modern React best practices including automatic cleanup, memory leak prevention, and performance optimization.
-
-## Table of Contents
-
-- [Quick Start](#quick-start)
-- [Core Hooks](#core-hooks)
-- [Best Practices](#best-practices)
-- [Performance Considerations](#performance-considerations)
-- [Error Handling](#error-handling)
-- [Migration Guide](#migration-guide)
-- [Examples](#examples)
+React hooks for `react-native-sensitive-info`. Each hook manages its own async state, error handling, and cleanup automatically.
## Quick Start
-### Installation
-
```bash
npm install react-native-sensitive-info
# or
yarn add react-native-sensitive-info
```
-### Basic Usage
-
```tsx
import { useSecretItem, useSecureStorage } from 'react-native-sensitive-info/hooks'
function MyComponent() {
- // Read a single secret
const { data, isLoading, error } = useSecretItem('apiToken')
-
- // Manage all secrets in a service
- const { items, saveSecret, removeSecret } = useSecureStorage({
- service: 'myapp'
- })
+ const { items, saveSecret, removeSecret } = useSecureStorage({ service: 'myapp' })
if (isLoading) return Loading...
if (error) return Error: {error.message}
@@ -47,38 +28,21 @@ function MyComponent() {
### `useSecretItem`
-Fetches and manages a single secure storage item with automatic loading and error states.
-
-#### API
+Fetches and manages a single secure storage item.
```typescript
function useSecretItem(
key: string,
- options?: SensitiveInfoOptions & {
+ options?: SensitiveInfoOptions & {
includeValue?: boolean
skip?: boolean
}
-): AsyncState & {
+): AsyncState & {
refetch: () => Promise
}
-
-interface AsyncState {
- data: TData | null
- error: HookError | null
- isLoading: boolean
- isPending: boolean
-}
```
-#### Features
-
-- ✅ Automatic request cancellation on unmount
-- ✅ Memory leak prevention via cleanup
-- ✅ Conditional loading with `skip` parameter
-- ✅ Manual refetch support
-- ✅ Type-safe error handling
-
-#### Example
+Returns `data`, `error`, `isLoading`, `isPending`, and a `refetch` function. Pass `skip: true` to defer loading until a condition is met.
```tsx
function TokenViewer() {
@@ -104,13 +68,9 @@ function TokenViewer() {
}
```
----
-
### `useSecret`
-A convenience hook that combines reading and writing a single secret. Includes save and delete operations.
-
-#### API
+Read and write a single secret from one hook. Returns the same async state as `useSecretItem` plus `saveSecret`, `deleteSecret`, and `refetch`.
```typescript
function useSecret(
@@ -123,31 +83,16 @@ function useSecret(
}
```
-#### Features
-
-- ✅ Read and write in a single hook
-- ✅ Automatic state synchronization after mutations
-- ✅ Optimized for single secret management
-
-#### Example
-
```tsx
function AuthTokenManager() {
- const {
- data: token,
- isLoading,
- saveSecret,
- deleteSecret,
- refetch
- } = useSecret('authToken', { service: 'myapp' })
+ const { data: token, isLoading, saveSecret, deleteSecret, refetch } = useSecret('authToken', {
+ service: 'myapp'
+ })
const handleLogout = async () => {
const { success, error } = await deleteSecret()
- if (success) {
- navigateTo('Login')
- } else {
- showError(error?.message)
- }
+ if (success) navigateTo('Login')
+ else showError(error?.message)
}
const handleRefreshToken = async (newToken: string) => {
@@ -168,31 +113,19 @@ function AuthTokenManager() {
}
```
----
-
### `useHasSecret`
-Lightweight hook for checking if a secret exists without fetching its value.
-
-#### API
+Checks if a secret exists without fetching its value. Useful for conditional rendering.
```typescript
function useHasSecret(
key: string,
options?: SensitiveInfoOptions & { skip?: boolean }
-): AsyncState & {
+): AsyncState & {
refetch: () => Promise
}
```
-#### Features
-
-- ✅ Efficient existence checks
-- ✅ Minimal performance overhead
-- ✅ No decryption needed
-
-#### Example
-
```tsx
function ConditionalContent() {
const { data: tokenExists, isLoading } = useHasSecret('apiToken')
@@ -203,17 +136,13 @@ function ConditionalContent() {
}
```
----
-
### `useSecureStorage`
-Manages all secrets in a service with full CRUD operations and automatic state synchronization.
-
-#### API
+Manages all secrets in a service. Returns an `items` array plus `saveSecret`, `removeSecret`, `clearAll`, and `refreshItems`.
```typescript
function useSecureStorage(
- options?: SensitiveInfoOptions & {
+ options?: SensitiveInfoOptions & {
includeValues?: boolean
skip?: boolean
}
@@ -228,52 +157,21 @@ function useSecureStorage(
}
```
-#### Features
-
-- ✅ Full CRUD operations
-- ✅ Optimistic updates for delete
-- ✅ Automatic list refresh after save/delete
-- ✅ Selective value inclusion
-- ✅ Service-wide operations
-
-#### Example
+Set `includeValues: false` to skip decryption when you only need keys and metadata.
```tsx
function SecureStorageManager() {
- const {
- items,
- isLoading,
- error,
- saveSecret,
- removeSecret,
- clearAll,
- refreshItems
- } = useSecureStorage({
- service: 'credentials',
- includeValues: false // Don't fetch values initially
- })
+ const { items, isLoading, error, saveSecret, removeSecret, clearAll, refreshItems } =
+ useSecureStorage({ service: 'credentials', includeValues: false })
const handleAddSecret = async () => {
const { success, error: err } = await saveSecret('apiKey', 'secret-value')
- if (!success) {
- showError(err?.message)
- }
+ if (!success) showError(err?.message)
}
const handleRemoveSecret = async (key: string) => {
const { success } = await removeSecret(key)
- if (success) {
- showNotification(`Deleted ${key}`)
- }
- }
-
- const handleClearAll = async () => {
- if (confirm('Delete all secrets?')) {
- const { success } = await clearAll()
- if (success) {
- showNotification('All secrets cleared')
- }
- }
+ if (success) showNotification(`Deleted ${key}`)
}
if (isLoading) return
@@ -284,28 +182,21 @@ function SecureStorageManager() {
(
- handleRemoveSecret(item.key)}
- />
+ handleRemoveSecret(item.key)} />
)}
keyExtractor={item => item.key}
/>
-
+
)
}
```
----
-
### `useSecurityAvailability`
-Fetches and caches device security capabilities (Secure Enclave, StrongBox, Biometry, etc.).
-
-#### API
+Fetches device security capabilities (Secure Enclave, StrongBox, Biometry).
```typescript
function useSecurityAvailability(
@@ -313,65 +204,39 @@ function useSecurityAvailability(
): AsyncState & {
refetch: () => Promise
}
-
-interface UseSecurityAvailabilityOptions {
- /** Auto-refresh when the app returns to `active`. Debounced ~500 ms. */
- readonly refreshOnForeground?: boolean
-}
-
-interface SecurityAvailability {
- readonly secureEnclave: boolean
- readonly strongBox: boolean
- readonly biometry: boolean
- readonly biometryStatus:
- | 'available'
- | 'notEnrolled'
- | 'notAvailable'
- | 'lockedOut'
- | 'unknown'
- readonly deviceCredential: boolean
-}
```
-#### Features
+| Option | Default | Description |
+|--------|---------|-------------|
+| `refreshOnForeground` | `false` | Subscribes to `AppState` and refetches when the user returns from system settings. |
-- ✅ Result cached **per component instance** — no native call on re-render
-- ✅ `refetch()` available to bypass the cache after settings changes
-- ✅ Previous data preserved on error
-- ✅ `biometryStatus` distinguishes *no hardware* from *hardware present but unenrolled* — drive an *“Enroll Face ID”* CTA off `'notEnrolled'` instead of hiding the toggle
-- ✅ `refreshOnForeground` subscribes to `AppState` and refetches when the user returns from system settings (off by default)
-
-#### Example
+`SecurityAvailability` exposes `secureEnclave`, `strongBox`, `biometry`, `deviceCredential` (booleans) and `biometryStatus` (`'available'`, `'notEnrolled'`, `'notAvailable'`, `'lockedOut'`, `'unknown'`).
```tsx
function AccessControlSelector() {
- const { data: capabilities, isLoading } = useSecurityAvailability({
- refreshOnForeground: true,
- })
+ const { data: capabilities, isLoading } = useSecurityAvailability({ refreshOnForeground: true })
if (isLoading) return Detecting capabilities...
if (capabilities?.biometryStatus === 'notEnrolled') {
return (
Linking.openSettings()}>
- Set up Face ID / fingerprint →
+ Set up Face ID / fingerprint
)
}
return (
- {capabilities?.secureEnclave && ✓ Secure Enclave available}
- {capabilities?.biometry && ✓ Biometry available}
- {capabilities?.deviceCredential && ✓ Device credential available}
+ {capabilities?.secureEnclave && Secure Enclave available}
+ {capabilities?.biometry && Biometry available}
+ {capabilities?.deviceCredential && Device credential available}
)
}
```
-#### React to enrollment changes
-
-Use `useBiometryStatusWatcher` for transition-only callbacks (fires once per real `BiometryStatus` change, never on every render):
+React to enrollment changes with `useBiometryStatusWatcher`, which fires once per real status change:
```tsx
import { useBiometryStatusWatcher } from 'react-native-sensitive-info/hooks'
@@ -383,9 +248,7 @@ useBiometryStatusWatcher((next, previous) => {
})
```
-#### Gate writes on a specific access-control policy
-
-Pair the snapshot with `canUseAccessControlSync` so the toggle reflects whether the policy you intend to use will actually succeed:
+Gate writes on a specific access-control policy by pairing with `canUseAccessControlSync`:
```tsx
import { canUseAccessControlSync } from 'react-native-sensitive-info'
@@ -396,13 +259,9 @@ const canEnableSecureEnclave = caps
: false
```
----
-
### `useKeyRotation`
-Manage versioned master-key rotation for a given service. Calls `rotateKeys()` under the hood and keeps the active version, last rotation result, and loading/error state.
-
-#### API
+Manages versioned master-key rotation for a service. Tracks the active version and last rotation result.
```typescript
function useKeyRotation(options?: UseKeyRotationOptions): {
@@ -412,39 +271,23 @@ function useKeyRotation(options?: UseKeyRotationOptions): {
rotate: () => Promise
readVersion: () => Promise
}
-
-interface UseKeyRotationOptions extends SensitiveInfoOptions {
- reEncryptEagerly?: boolean // default: false (lazy rotation)
-}
-
-interface RotationResult {
- previousVersion: number
- newVersion: number
- reEncryptedCount: number
-}
```
-#### Example
+Defaults to lazy rotation, re-encrypting entries when they are next read. Pass `reEncryptEagerly: true` to re-encrypt all entries up front.
```tsx
-import { useKeyRotation } from 'react-native-sensitive-info/hooks'
-
function RotationButton() {
- const { rotate, isRotating, lastResult, error } = useKeyRotation({
- service: 'auth',
- })
+ const { rotate, isRotating, lastResult, error } = useKeyRotation({ service: 'auth' })
return (
{lastResult && (
-
- v{lastResult.previousVersion} → v{lastResult.newVersion}
-
+ v{lastResult.previousVersion} to v{lastResult.newVersion}
)}
{error && {error.message}}
@@ -452,288 +295,90 @@ function RotationButton() {
}
```
-> **Note:** Defaults to lazy rotation — entries are re-encrypted opportunistically when they are next read. Pass `reEncryptEagerly: true` to walk every entry up front.
-
----
-
### `useSecureOperation`
-One-time operation hook for non-reactive operations (e.g., bulk operations, logout).
-
-#### API
+One-time operation hook for non-reactive flows like logout or bulk cleanup.
```typescript
function useSecureOperation(): VoidAsyncState & {
execute: (operation: () => Promise) => Promise
}
-
-interface VoidAsyncState {
- error: HookError | null
- isLoading: boolean
- isPending: boolean
-}
```
-#### Features
-
-- ✅ Flexible operation execution
-- ✅ Loading state management
-- ✅ Error handling
-
-#### Example
-
```tsx
function LogoutButton() {
const { execute, isLoading, error } = useSecureOperation()
const handleLogout = async () => {
await execute(async () => {
- // Clear all app credentials
await clearService({ service: 'auth' })
await clearService({ service: 'cache' })
- // Navigate to login
navigateTo('Login')
})
}
if (error) return Logout failed: {error.message}
- return (
-
- )
-}
-```
-
----
-
-## No Setup Required
-
-All hooks work independently without any provider. Just import and use them directly in your components:
-
-```tsx
-import {
- useSecureStorage,
- useSecurityAvailability,
-} from 'react-native-sensitive-info/hooks'
-
-function MyComponent() {
- const { items } = useSecureStorage({ service: 'myapp' })
- const { data: capabilities } = useSecurityAvailability()
-
- // Each hook instance keeps its own cache. Mounting `useSecurityAvailability`
- // in two components issues two native reads (one per instance), but neither
- // re-runs across re-renders unless you call `refetch()`.
+ return
}
```
----
-
## Best Practices
-### 1. Memory Leak Prevention ✅
-
-All hooks automatically clean up resources on unmount:
-
-```tsx
-// ✅ GOOD: Automatic cleanup
-function Component() {
- const { data, isLoading } = useSecretItem('token')
- // Cleanup happens automatically on unmount
-}
-```
-
-### 2. Avoid Unnecessary Re-renders
-
-Use the `skip` parameter to conditionally skip fetches:
+### Use `skip` for conditional fetching
```tsx
-// ✅ GOOD: Conditional fetching
function Component() {
const isAuthenticated = useIsAuthenticated()
const { data } = useSecretItem('token', { skip: !isAuthenticated })
- // Won't fetch until user is authenticated
}
```
-### 3. Use `useMemo` for Options
+### Prefer `useSecureStorage` over multiple `useSecretItem` calls
-Stabilize options objects to prevent unnecessary API calls:
+A single `useSecureStorage` instance manages the entire service. Mounting separate `useSecretItem` hooks for every key creates separate async pipelines and cache entries.
-```tsx
-// ✅ GOOD: Memoized options
-const options = useMemo(() => ({
- service: 'myapp',
- accessControl: 'secureEnclaveBiometry'
-}), []) // Empty deps - only create once
-
-const { data } = useSecretItem('token', options)
-
-// ❌ BAD: New object every render
-const { data } = useSecretItem('token', {
- service: 'myapp',
- accessControl: 'secureEnclaveBiometry'
-})
-```
+### Selective value fetching
-### 4. Handle Errors Gracefully
+Pass `includeValues: false` when you only need keys or metadata. Skipping decryption reduces latency and avoids prompting the user for biometrics unnecessarily.
-Always check error states and provide user feedback:
+### Gate biometric UI on `biometryStatus`
-```tsx
-// ✅ GOOD: Proper error handling
-function Component() {
- const { data, error, isLoading } = useSecretItem('token')
-
- if (isLoading) return
- if (error) return
- if (!data) return No data found
-
- return {data.value}
-}
-```
-
-### 5. Batch Operations
-
-Use `useSecureStorage` instead of multiple `useSecretItem` calls:
-
-```tsx
-// ✅ GOOD: Single hook for multiple items
-function Component() {
- const { items } = useSecureStorage({ service: 'auth' })
- // Access all items
-}
-
-// ❌ AVOID: Multiple hook instances
-const token = useSecretItem('token')
-const refresh = useSecretItem('refreshToken')
-const apiKey = useSecretItem('apiKey')
-```
-
-### 6. Capability Caching Is Per-Instance
-
-Each `useSecurityAvailability` mount keeps its own cache, so re-renders never trigger a fresh
-native call. Multiple components mounting the hook will each issue one read — if you need a
-single source of truth, lift the hook into a parent and pass `data` down via props.
-
-```tsx
-// ✅ Re-renders are free — first mount caches, subsequent renders reuse the value.
-function Capabilities() {
- const { data, isLoading, refetch } = useSecurityAvailability()
- // Call refetch() after the user changes biometric enrollment in system settings.
-}
-```
-
-### 7. Accessing Security Capabilities
-
-Check what security features are available on the device:
-
-```tsx
-// ✅ GOOD: Direct hook usage
-function SecurityStatus() {
- const { data: capabilities, isLoading } = useSecurityAvailability()
-
- if (isLoading) return
-
- return (
-
- Biometric: {capabilities?.isBiometricEnabled ? '✓' : '✗'}
- Strong Box: {capabilities?.isStrongBoxAvailable ? '✓' : '✗'}
-
- )
-}
-```
-
-### 8. Refetch Data Strategically
-
-Use `refetch()` when you need to sync state with native storage:
-
-```tsx
-// ✅ GOOD: Manual refetch after external updates
-const { data, refetch } = useSecretItem('token')
-
-const handleExternalUpdate = async () => {
- await externallyUpdateToken()
- await refetch() // Sync with native state
-}
-```
-
----
+Check `biometryStatus` instead of just the `biometry` boolean. The status tells you whether biometry is unavailable because the hardware is missing, unenrolled, or locked out. Drive enrollment CTAs off `'notEnrolled'`.
## Performance Considerations
-### 1. Request Cancellation
+### `useSecurityAvailability` caches per instance
-All hooks automatically cancel in-flight requests on unmount:
+Each mount calls native code once. Re-renders reuse the cached value. Multiple components mounting the hook each issue their own read. Lift the hook into a parent and pass `data` down if you need a single source of truth.
-```tsx
-// If component unmounts while fetching, request is cancelled
-const { data, isLoading } = useSecretItem('token')
-```
+### Previous data preserved on error
-### 2. Caching
+When a fetch fails, `data` retains the last successful value instead of resetting to `null`. This prevents UI flicker during transient network or platform failures.
-`useSecurityAvailability` caches results to avoid repeated native calls:
+### In-flight requests cancel on unmount
-```tsx
-const cap1 = useSecurityAvailability() // Calls native
-const cap2 = useSecurityAvailability() // Uses cache
-```
-
-### 3. Selective Value Fetching
-
-Use `includeValues: false` when you only need metadata:
-
-```tsx
-// ✅ GOOD: Only fetch metadata
-const { items } = useSecureStorage({ includeValues: false })
-
-// ❌ AVOID: Unnecessary decryption
-const { items } = useSecureStorage({ includeValues: true })
-```
-
-### 4. Optimistic Updates
-
-Delete operations update UI immediately:
-
-```tsx
-const { removeSecret } = useSecureStorage()
-
-// UI updates immediately, native call happens in background
-await removeSecret('token') // Optimistic delete
-```
-
----
+All hooks abort pending operations when the component unmounts. You do not need to implement manual cleanup.
## Error Handling
-### Understanding Errors
-
-The `HookError` class wraps errors with context:
+`HookError` wraps the underlying native error with operation context:
```typescript
class HookError extends Error {
- constructor(
- message: string,
- public readonly originalError?: unknown
- ) {}
+ readonly operation?: string // e.g. 'useSecretItem.fetch'
+ readonly hint?: string // e.g. 'Ask the user to retry biometrics.'
+ readonly cause?: unknown // the original SensitiveInfoError
}
```
-### Error Handling Patterns
-
```tsx
function Component() {
const { error, data } = useSecretItem('token')
if (error) {
- // Log original error for debugging
- console.error('Hook error:', error.originalError)
-
- // Show user-friendly message
+ console.error(`[${error.operation}] ${error.message}`)
+ if (error.hint) console.warn(`Hint: ${error.hint}`)
return Failed to load token: {error.message}
}
@@ -741,74 +386,15 @@ function Component() {
}
```
----
-
-## Migration Guide
-
-### From Callback-Based API to Hooks
-
-#### Before (Callback API)
-
-```tsx
-function Component() {
- const [token, setToken] = useState(null)
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- let mounted = true
-
- const fetchToken = async () => {
- try {
- const item = await getItem('token')
- if (mounted) setToken(item)
- } catch (err) {
- if (mounted) setError(err)
- } finally {
- if (mounted) setLoading(false)
- }
- }
-
- fetchToken()
-
- return () => {
- mounted = false
- }
- }, [])
-
- return loading ? Loading : {token?.value}
-}
-```
-
-#### After (Hooks API)
-
-```tsx
-// ✅ MUCH CLEANER
-function Component() {
- const { data: token, isLoading, error } = useSecretItem('token')
- return isLoading ? Loading : {token?.value}
-}
-```
-
----
-
## Examples
-### Complete Authentication Flow
+### Authentication Flow
```tsx
-import {
- useSecret,
- useSecurityAvailability,
-} from 'react-native-sensitive-info/hooks'
+import { useSecret, useSecurityAvailability } from 'react-native-sensitive-info/hooks'
function AuthenticationFlow() {
- const {
- data: token,
- isLoading: tokenLoading,
- saveSecret,
- deleteSecret
- } = useSecret('authToken', {
+ const { data: token, saveSecret, deleteSecret } = useSecret('authToken', {
service: 'myapp',
accessControl: 'secureEnclaveBiometry'
})
@@ -818,24 +404,19 @@ function AuthenticationFlow() {
const handleLogin = async (credentials) => {
const response = await login(credentials)
const { success } = await saveSecret(response.token)
-
- if (success) {
- navigateTo('Home')
- }
+ if (success) navigateTo('Home')
}
const handleLogout = async () => {
const { success } = await deleteSecret()
- if (success) {
- navigateTo('Login')
- }
+ if (success) navigateTo('Login')
}
return token ? :
}
```
-### Biometric Authentication
+### Biometric Auth
```tsx
function BiometricAuth() {
@@ -844,118 +425,18 @@ function BiometricAuth() {
const canUseBiometry = capabilities?.biometry ?? false
- if (!canUseBiometry) {
- return Biometry not available
- }
+ if (!canUseBiometry) return Biometry not available
return (