diff --git a/README.md b/README.md index 4307dae2..830f7e06 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](https://github.com/mcodex/react-native-sensitive-info) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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} />