diff --git a/CHANGELOG.md b/CHANGELOG.md index eccd138e..a55f8aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,13 @@ -## [6.0.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.12...v6.0.0) (2026-04-28) - -First stable release of the Nitro-based v6 line. Promotes `6.0.0-rc.12` to GA with no API changes — the release notes below summarize everything new since the v5 line. +## [6.1.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.12...v6.1.0) (2026-04-28) ### Features -* **rotation:** Add versioned key rotation via `rotateKeys()` and `getKeyVersion()` with lazy re-encryption on read. New `useKeyRotation` hook exposes the same flow declaratively. -* **security hardening:** Defense-in-depth pass — non-breaking, applied transparently to new writes and via lazy upgrade on rotation: - - HMAC-SHA256 integrity tag bound to every entry's metadata + ciphertext, surfaced on `StorageMetadata.integrityTag`. Tampering with SharedPreferences/Keychain attributes now raises `IntegrityViolationError` (`E_INTEGRITY_VIOLATION`) before any biometric prompt is shown. - - AES-GCM AAD on Android binds ciphertext to `service|key|v`, defeating cross-entry swap attacks. - - `setUnlockedDeviceRequired(true)` on every Android Keystore key (API 28+), mirroring iOS's `kSecAttrAccessibleWhenUnlocked` semantics. - - Plaintext byte buffers are zeroized after use on both platforms. - - Constant-time HMAC comparison via `MessageDigest.isEqual` / manual `UInt8` XOR fold. - - Backwards compatible: entries written by earlier versions decode without verification and are upgraded on the next write or rotation. -* **errors:** New typed error classes (`SensitiveInfoError`, `NotFoundError`, `AuthenticationCanceledError`, `IntegrityViolationError`, `KeyInvalidatedError`, `RotationFailedError`) with `code` discriminants and `instanceof` predicates. Importable from the `react-native-sensitive-info/errors` subpath. -* **tree-shaking:** `"sideEffects": false` everywhere; the package now publishes three focused subpath entries (`.`, `/hooks`, `/errors`). The default export has been removed — import only the helpers you use. -* **nitro 0.35:** Regenerated against `nitrogen@0.35.5` and `react-native-nitro-modules@0.35.5`. -* **tooling:** Migrated linting/formatting from ESLint + Prettier to **Biome 2**. Single config at `biome.json`, faster CI runs. - -### Refactor (KISS · DRY · SRP) - -* Introduced `useAsyncQuery` (read-only hooks) and `useMutation` (mutation hooks) primitives. `useHasSecret`, `useSecretItem`, `useSecureOperation`, `useKeyRotation`, and `useSecureStorage` now compose the same lifecycle/abort/error-handling pipeline — no duplicated state machines. -* `useSecureStorage` shrunk from ~230 LOC to ~180 LOC and reuses the shared abort + auth-cancel semantics; behaviour is unchanged. -* Test fixtures consolidated in `src/__tests__/__mocks__/fixtures.ts` (`buildTestItem`, `buildTestMetadata`). -* Removed redundant re-exports from `src/internal/errors.ts`. - -### Breaking changes +* add AccessControlCard and DiagnosticsCard components; remove unused components ([6701d84](https://github.com/mcodex/react-native-sensitive-info/commit/6701d84226b97cf587064c596f871e8395aa7250)) +* implement integrity hardening for sensitive data storage ([60b2cf5](https://github.com/mcodex/react-native-sensitive-info/commit/60b2cf5c8520cb40f917a698f326a239861e5d10)) -* The default export is gone. Use named imports: `import { setItem } from 'react-native-sensitive-info'`. -* React hooks are no longer re-exported from the package root — import them from `react-native-sensitive-info/hooks`. - -### Notes - -* **iOS rotation** updates the Keychain metadata via `SecItemUpdate`, preserving the original access-control attributes while bumping `keyVersion`. -* **Android rotation** mints a fresh per-entry Keystore alias (`SensitiveInfo__v`) during lazy or eager re-encryption and deletes the stale alias after a successful rewrite. -* Version state lives in a non-secret registry (`SharedPreferences` on Android, `UserDefaults` on iOS). Delete the app's data to reset. +### Bug Fixes +* update module resolver alias to correctly map source directory for subpath imports ([106621e](https://github.com/mcodex/react-native-sensitive-info/commit/106621e8d4c0cba81987196054a971628b4a36d8)) ## [6.0.0-rc.12](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.11...v6.0.0-rc.12) (2025-12-16) ### Features diff --git a/README.md b/README.md index ec66a022..2774101a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Modern secure storage for React Native, powered by Nitro Modules. Version 6 ship - [⚡️ Quick start](#-quick-start) - [📚 API reference](#-api-reference) - [🔐 Access control & metadata](#-access-control--metadata) +- [👁️ Biometrics](#️-biometrics) - [❗ Error handling](#-error-handling) - [🔁 Key rotation](#-key-rotation) - [🛡️ Security model](#-security-model) @@ -430,11 +431,79 @@ See `src/sensitive-info.nitro.ts` for full TypeScript definitions. - **Access policies** — `secureEnclaveBiometry`, `biometryCurrentSet`, `biometryAny`, `devicePasscode`, `none`. - **Timestamp** — UNIX seconds when the entry was last written. -Use `getSupportedSecurityLevels()` to tailor UX before prompting users. For example, disable Secure Enclave options on simulators. +Use `getSupportedSecurityLevels()` to tailor UX before prompting users. For example, disable Secure Enclave options on simulators. For richer enrollment-state detection (so you can distinguish *"hardware missing"* from *"user hasn't enrolled yet"*), see [👁️ Biometrics](#️-biometrics). > [!TIP] > Need to demo biometrics on a simulator? Use Xcode’s “Features → Face ID” and Android Studio’s “Fingerprints” toggles to simulate successful scans. +## 👁️ Biometrics + +The library disambiguates **capability** from **enrollment** so you can render the right UX without false positives. `SecurityAvailability` exposes both a quick boolean (`biometry`) and a fine-grained `biometryStatus` enum: + +| `biometryStatus` | Meaning | Recommended UX | +| --- | --- | --- | +| `'available'` | Hardware present, enrolled, currently usable. | Enable the biometric toggle. | +| `'notEnrolled'` | Hardware present but no fingerprint/face is registered. | Show a *“Set up Face ID / fingerprint”* CTA that deep-links to settings. | +| `'notAvailable'` | Missing or permanently disabled (no hardware, admin policy, passcode unset). | Hide the biometric toggle entirely. | +| `'lockedOut'` | Too many failed attempts; transiently locked. iOS only at probe time — Android surfaces lockout via `BiometricPrompt` failures. | Show *“Try again later”* and offer a `devicePasscode` fallback. | +| `'unknown'` | Probe could not classify the device. | Treat as `notAvailable` for gating; log for diagnostics. | + +> Invariant: `biometry === (biometryStatus === 'available')`. Both fields come from the same native probe. + +### Gate a toggle on a specific access-control policy + +`canUseAccessControl(policy)` predicts whether a future `setItem` write with the requested policy will succeed on the current device. It maps the policy onto a [`SecurityAvailability`](#-access-control--metadata) snapshot — pure TS, no native call — but if you don't pass a snapshot it first fetches one via `getSupportedSecurityLevels()`. Pass the snapshot you already hold (e.g. from `useSecurityAvailability`) to skip that round-trip: + +```ts +import { canUseAccessControl, setItem } from 'react-native-sensitive-info' + +if (await canUseAccessControl('secureEnclaveBiometry')) { + await setItem('session', token, { accessControl: 'secureEnclaveBiometry' }) +} else { + // Graceful fallback so the user can still sign in. + await setItem('session', token, { accessControl: 'devicePasscode' }) +} +``` + +If you already hold a snapshot from `useSecurityAvailability`, use the synchronous variant inside render: + +```tsx +import { canUseAccessControlSync } from 'react-native-sensitive-info' +import { useSecurityAvailability } from 'react-native-sensitive-info/hooks' + +const { data: caps } = useSecurityAvailability() +const canEnable = caps ? canUseAccessControlSync('secureEnclaveBiometry', caps) : false +``` + +### Auto-refresh when the user returns from system settings + +Users commonly leave the app to enroll a fingerprint and come back. Opt into foreground auto-refresh so the toggle reflects the new state without a manual `refetch()`: + +```tsx +const { data: caps } = useSecurityAvailability({ refreshOnForeground: true }) + +if (caps?.biometryStatus === 'notEnrolled') { + return Linking.openSettings()} /> +} +``` + +The hook subscribes to `AppState` only when the option is enabled, debounces back-to-back `active` transitions (~500 ms), and unsubscribes on unmount. + +### React to enrollment changes + +`useBiometryStatusWatcher` is a transition-only callback (fires once per actual `BiometryStatus` change, never on every render): + +```tsx +import { useBiometryStatusWatcher } from 'react-native-sensitive-info/hooks' + +useBiometryStatusWatcher((next, previous) => { + analytics.track('biometry_status_changed', { from: previous, to: next }) + if (previous === 'notEnrolled' && next === 'available') showToast('Face ID is ready.') +}) +``` + +It lives in its own module, so apps that don’t need transition tracking don’t pay for it (`sideEffects: false` + named exports keep tree-shaking honest). + ## 🧪 Simulators and emulators - iOS simulators do not offer Secure Enclave hardware. Biometric prompts usually fall back to a passcode dialog. diff --git a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt index 08532ee0..a89b978e 100644 --- a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt +++ b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt @@ -218,6 +218,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() { secureEnclave = capabilities.secureEnclave, strongBox = capabilities.strongBox, biometry = capabilities.biometry, + biometryStatus = capabilities.biometryStatus, deviceCredential = capabilities.deviceCredential ) } @@ -398,11 +399,47 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() { val activeVersion = deps.keyVersionRegistry.get(service) if (entry.keyVersion >= activeVersion) return entry + // Skip lazy re-encryption for biometry-protected entries. Re-encryption + // creates a new Keystore key alias for `activeVersion` and `Cipher.init` on + // a `setUserAuthenticationRequired(true)` key requires its own biometric + // authorization — surfacing as a *second* Face/fingerprint prompt right + // after the user already authenticated for the read. + // + // These items are still upgraded by: + // - the next explicit `setItem` (caller-initiated full overwrite), or + // - `rotateKeys({ reEncryptEagerly: true })` where the prompt is expected. + if (requiresBiometricAuth(entry)) return entry + return runCatching { reEncryptEntry(deps, service, key, entry, plaintext, activeVersion, prompt) }.getOrDefault(entry) } + /** + * 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). + */ + private fun requiresBiometricAuth(entry: PersistedEntry): Boolean { + if (entry.requiresAuthentication) return true + val accessControl = entry.metadata.toStorageMetadata()?.accessControl + ?: return false + return when (accessControl) { + AccessControl.SECUREENCLAVEBIOMETRY, + AccessControl.BIOMETRYCURRENTSET, + AccessControl.BIOMETRYANY -> true + AccessControl.DEVICEPASSCODE, + AccessControl.NONE -> false + } + } + private suspend fun reEncryptEntry( deps: Dependencies, service: String, diff --git a/android/src/main/java/com/sensitiveinfo/internal/crypto/SecurityAvailabilityResolver.kt b/android/src/main/java/com/sensitiveinfo/internal/crypto/SecurityAvailabilityResolver.kt index f8e4de1b..bb02287d 100644 --- a/android/src/main/java/com/sensitiveinfo/internal/crypto/SecurityAvailabilityResolver.kt +++ b/android/src/main/java/com/sensitiveinfo/internal/crypto/SecurityAvailabilityResolver.kt @@ -6,6 +6,7 @@ import android.os.Build import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators import androidx.core.content.getSystemService +import com.margelo.nitro.sensitiveinfo.BiometryStatus import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -13,6 +14,7 @@ internal data class SecurityAvailabilitySnapshot( val secureEnclave: Boolean, val strongBox: Boolean, val biometry: Boolean, + val biometryStatus: BiometryStatus, val strongBiometrics: Boolean, val deviceCredential: Boolean ) @@ -36,10 +38,16 @@ internal class SecurityAvailabilityResolver(private val context: Context) { } val biometricManager = BiometricManager.from(context) - val hasStrongBiometrics = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS - val hasWeakBiometrics = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS + val strongResult = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG) + val weakResult = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_WEAK) + val hasStrongBiometrics = strongResult == BiometricManager.BIOMETRIC_SUCCESS + val hasWeakBiometrics = weakResult == BiometricManager.BIOMETRIC_SUCCESS val hasBiometry = hasStrongBiometrics || hasWeakBiometrics + // Combine the strong/weak probe results so that the most informative reason wins. + // Order of precedence: SUCCESS > NONE_ENROLLED > NO_HARDWARE/HW_UNAVAILABLE/SECURITY_UPDATE_REQUIRED > UNKNOWN/UNSUPPORTED. + val biometryStatus = classifyBiometryStatus(strongResult, weakResult) + val keyguard = context.getSystemService() val deviceCredential = keyguard?.isDeviceSecure == true @@ -50,6 +58,7 @@ internal class SecurityAvailabilityResolver(private val context: Context) { secureEnclave = hasStrongBox, strongBox = hasStrongBox, biometry = hasBiometry, + biometryStatus = biometryStatus, strongBiometrics = hasStrongBiometrics, deviceCredential = deviceCredential ) @@ -57,4 +66,32 @@ internal class SecurityAvailabilityResolver(private val context: Context) { return snapshot } } + + private fun classifyBiometryStatus(strongResult: Int, weakResult: Int): BiometryStatus { + // SUCCESS on either tier means we can authenticate now. + if (strongResult == BiometricManager.BIOMETRIC_SUCCESS || + weakResult == BiometricManager.BIOMETRIC_SUCCESS + ) { + return BiometryStatus.AVAILABLE + } + + // Hardware exists but no fingerprint/face is enrolled. + if (strongResult == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED || + weakResult == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED + ) { + return BiometryStatus.NOTENROLLED + } + + // Permanently or contextually unavailable. + val unavailableCodes = setOf( + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED + ) + if (strongResult in unavailableCodes || weakResult in unavailableCodes) { + return BiometryStatus.NOTAVAILABLE + } + + return BiometryStatus.UNKNOWN + } } diff --git a/babel.config.js b/babel.config.js index 56e23e62..eb01964a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,36 @@ -module.exports = { - plugins: ['babel-plugin-react-compiler'], - presets: ['module:@react-native/babel-preset'], +/** + * The library's babel config has a single job: ensure + * `babel-plugin-react-compiler` runs on every code path that produces + * shipped JavaScript. + * + * - When `react-native-builder-bob` invokes us (bob targets set + * `configFile: true` so they pick up this file), we extend bob's own + * preset — it already takes care of `@babel/preset-env`, JSX, TS, and + * import-extension rewriting. Adding our own RN preset would mis-target + * the published bundle for Hermes only. + * - For any other caller (jest is ts-jest only and ignores this file, but + * IDE tooling and `metro` in the example app may load it), fall back to + * the standard React Native preset. + * + * The compiler plugin is listed BEFORE other plugins so it operates on + * pristine source. + */ +module.exports = (api) => { + const isBob = api.caller((caller) => + caller != null ? caller.name === 'react-native-builder-bob' : false + ) + + const plugins = ['babel-plugin-react-compiler'] + + if (isBob) { + return { + presets: [require.resolve('react-native-builder-bob/babel-preset')], + plugins, + } + } + + return { + presets: ['module:@react-native/babel-preset'], + plugins, + } } diff --git a/docs/HOOKS.md b/docs/HOOKS.md index c57df3b4..91c9b1d9 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -308,14 +308,27 @@ Fetches and caches device security capabilities (Secure Enclave, StrongBox, Biom #### API ```typescript -function useSecurityAvailability(): AsyncState & { +function useSecurityAvailability( + options?: UseSecurityAvailabilityOptions +): 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 } ``` @@ -325,31 +338,64 @@ interface SecurityAvailability { - ✅ 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 ```tsx function AccessControlSelector() { - const { data: capabilities, isLoading } = useSecurityAvailability() + const { data: capabilities, isLoading } = useSecurityAvailability({ + refreshOnForeground: true, + }) if (isLoading) return Detecting capabilities... + if (capabilities?.biometryStatus === 'notEnrolled') { + return ( + Linking.openSettings()}> + 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): + +```tsx +import { useBiometryStatusWatcher } from 'react-native-sensitive-info/hooks' + +useBiometryStatusWatcher((next, previous) => { + if (previous === 'notEnrolled' && next === 'available') { + showToast('Face ID is ready.') + } +}) +``` + +#### 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: + +```tsx +import { canUseAccessControlSync } from 'react-native-sensitive-info' + +const { data: caps } = useSecurityAvailability() +const canEnableSecureEnclave = caps + ? canUseAccessControlSync('secureEnclaveBiometry', caps) + : false +``` + --- ### `useKeyRotation` diff --git a/example/babel.config.js b/example/babel.config.js index 9a09d3c6..cbd6bc6b 100644 --- a/example/babel.config.js +++ b/example/babel.config.js @@ -6,6 +6,10 @@ module.exports = (api) => { return { presets: ['module:@react-native/babel-preset'], plugins: [ + // Run the React Compiler first so it sees the original source before any + // other transforms rewrite it. Default target is React 19, which matches + // the RN 0.85 / React 19.2 runtime shipped by this example. + 'babel-plugin-react-compiler', [ 'module-resolver', { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 5c662b48..35b30d69 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1780,9 +1780,9 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/core - ReactNativeDependencies - - ReactAppDependencyProvider (0.83.0): + - ReactAppDependencyProvider (0.85.2): - ReactCodegen - - ReactCodegen (0.83.0): + - ReactCodegen (0.85.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1839,7 +1839,7 @@ PODS: - React-utils (= 0.85.2) - ReactNativeDependencies - ReactNativeDependencies (0.85.2) - - SensitiveInfo (6.0.0-rc.12): + - SensitiveInfo (6.0.0): - hermes-engine - NitroModules - RCTRequired @@ -2170,11 +2170,11 @@ SPEC CHECKSUMS: React-timing: 9f1af3753eef091257c424d3856cd6cbedcad01e React-utils: 862e61256698fe3841aec1af476abf924a6737c3 React-webperformancenativemodule: e745e43264767cb8f4334fc11bf3fbff880050d0 - ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 - ReactCodegen: afce03c7835617f915bec50e2acdcfc5167c3bf7 + ReactAppDependencyProvider: 22e2265d86a4e871e5e858f4e7ef1c8d01103680 + ReactCodegen: 75cd4d6498ab93ae4eed4d384b78383987e7558e ReactCommon: a804bb8d1dcf3ecdec3a77eb8bba19b7863bbbdb ReactNativeDependencies: 16dfbcfc63bf756df236d05cd69638f95019c528 - SensitiveInfo: a70cb7de82bfc20322b8ace32fdc5bff21219615 + SensitiveInfo: 83b9376b4a48bc310795daa351a64a5495c58b1b Yoga: 04bb4bfeb02c0000b940c1e6e89e856cd8de5a71 PODFILE CHECKSUM: 7ee3efea19ddd1156f9f61f93fc84a48ff536985 diff --git a/example/package.json b/example/package.json index 48fb0aab..c852f448 100644 --- a/example/package.json +++ b/example/package.json @@ -27,7 +27,8 @@ "@react-native/metro-config": "0.85.2", "@react-native/typescript-config": "0.85.2", "@types/jest": "^30.0.0", - "babel-plugin-module-resolver": "^5.0.3" + "babel-plugin-module-resolver": "^5.0.3", + "babel-plugin-react-compiler": "^1.0.0" }, "engines": { "node": ">=22" diff --git a/example/src/App.tsx b/example/src/App.tsx index 6ef50513..d56bba58 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { ScrollView, StyleSheet, Text } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import AccessControlCard from './components/AccessControlCard' +import BiometryStatusCard from './components/BiometryStatusCard' import DiagnosticsCard from './components/DiagnosticsCard' import Footer from './components/Footer' import KeyRotationCard from './components/KeyRotationCard' @@ -27,6 +28,7 @@ const App = () => { StrongBox-backed policies. + +> = { + available: { label: 'Available', tone: '#065f46', bg: '#d1fae5' }, + notEnrolled: { label: 'Not enrolled', tone: '#92400e', bg: '#fef3c7' }, + notAvailable: { label: 'Not available', tone: '#991b1b', bg: '#fee2e2' }, + lockedOut: { label: 'Locked out', tone: '#9a3412', bg: '#ffedd5' }, + unknown: { label: 'Unknown', tone: '#475569', bg: '#e2e8f0' }, +} + +const openBiometricSettings = () => { + if (Platform.OS === 'ios') { + void Linking.openURL('App-Prefs:').catch(() => + Linking.openSettings().catch(() => {}) + ) + return + } + void Linking.sendIntent('android.settings.BIOMETRIC_ENROLL').catch(() => + Linking.openSettings().catch(() => {}) + ) +} + +const BiometryStatusCard = () => { + const [refreshOnForeground, setRefreshOnForeground] = useState(false) + const [transitionLog, setTransitionLog] = useState(null) + + const result = useSecurityAvailability({ refreshOnForeground }) + + const previousRef = useRef(null) + const status = result.data?.biometryStatus ?? null + useEffect(() => { + if (status === null) return + const prev = previousRef.current + if (prev === status) return + previousRef.current = status + setTransitionLog(`${prev ?? '∅'} → ${status}`) + }, [status]) + + const effectiveStatus: BiometryStatus = status ?? 'unknown' + const copy = STATUS_COPY[effectiveStatus] + + return ( +
+ + + + {copy.label} + + + {result.isLoading ? Checking… : null} + + + + + + + + + + Policy precheck + {POLICIES.map((policy) => { + const ok = result.data + ? canUseAccessControlSync(policy, result.data) + : false + return ( + + {policy} + + {ok ? 'OK' : 'blocked'} + + + ) + })} + + + Auto-refresh on foreground + + + + +
+ ) +} + +interface FlagProps { + readonly label: string + readonly value: boolean +} + +const Flag = ({ label, value }: FlagProps) => ( + + {label} + + {value ? '✓' : '—'} + + +) + +const styles = StyleSheet.create({ + badgeRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + badge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 }, + badgeText: { fontSize: 12, fontWeight: '700', letterSpacing: 0.4 }, + muted: { fontSize: 12, color: '#64748b' }, + flagsGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + flag: { + flexBasis: '48%', + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: '#f1f5f9', + }, + flagLabel: { fontSize: 12, color: '#475569' }, + flagValue: { fontSize: 12, fontWeight: '700' }, + sectionLabel: { + fontSize: 12, + fontWeight: '600', + color: '#0f172a', + marginTop: 4, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + policy: { fontSize: 13, color: '#0f172a' }, + policyValue: { fontSize: 13, fontWeight: '600' }, + actionRow: { flexDirection: 'row', gap: 8 }, +}) + +export default BiometryStatusCard diff --git a/example/src/components/DiagnosticsCard.tsx b/example/src/components/DiagnosticsCard.tsx index 3cc82438..c8efcfaf 100644 --- a/example/src/components/DiagnosticsCard.tsx +++ b/example/src/components/DiagnosticsCard.tsx @@ -35,6 +35,7 @@ const DiagnosticsCard = ({ readOptions }: DiagnosticsCardProps) => { ['Service', readOptions.service ?? 'default'], ['Active key version', version != null ? `v${version}` : '—'], ['Biometry', formatBoolean(availability?.biometry ?? false)], + ['Biometry status', availability?.biometryStatus ?? '—'], ['Secure Enclave', formatBoolean(availability?.secureEnclave ?? false)], ['StrongBox', formatBoolean(availability?.strongBox ?? false)], [ diff --git a/ios/HybridSensitiveInfo.swift b/ios/HybridSensitiveInfo.swift index 0148d81d..1982eada 100755 --- a/ios/HybridSensitiveInfo.swift +++ b/ios/HybridSensitiveInfo.swift @@ -97,8 +97,7 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { PersistedMetadata(metadata: metadata, integrityTag: tag) ) - deleteExisting(query: query) - var status = SecItemAdd(attributes as CFDictionary, nil) + var status = upsertKeychainEntry(baseQuery: query, attributes: attributes) if status == errSecSuccess { return MutationResult(metadata: metadata) } @@ -130,7 +129,7 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { PersistedMetadata(metadata: fallbackMetadata, integrityTag: fallbackTag) ) - status = SecItemAdd(fallbackAttributes as CFDictionary, nil) + status = upsertKeychainEntry(baseQuery: query, attributes: fallbackAttributes) if status == errSecSuccess { return MutationResult(metadata: fallbackMetadata) } @@ -325,11 +324,47 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { return query } - private func deleteExisting(query: [String: Any]) { + /// Persists `attributes` for the slot identified by `baseQuery`, replacing + /// any prior entry — including an iCloud-synced sibling that the caller may + /// not currently be writing. + /// + /// Why a dedicated helper instead of a plain `SecItemAdd`? + /// 1. Keychain queries default to *non-synchronizable items only* when + /// `kSecAttrSynchronizable` is omitted. A delete-then-add cycle that uses + /// only the caller's `iosSynchronizable` flag can leave a stale entry in + /// the opposite state, so the next `SecItemAdd` returns + /// `errSecDuplicateItem`. We always force-delete with + /// `kSecAttrSynchronizableAny` to avoid that trap. + /// 2. iCloud Keychain sync can re-insert an entry between our delete and our + /// add. We absorb that race with a single bounded retry — `setItem` + /// semantically means *"the slot now contains X"*, and the Keychain + /// partition is already scoped to bundle ID + access group, so any + /// matched entry is provably ours to overwrite. + private func upsertKeychainEntry( + baseQuery: [String: Any], + attributes: [String: Any] + ) -> OSStatus { + forceDeleteExisting(query: baseQuery) + var status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecDuplicateItem else { return status } + + // Race: another process (typically iCloud sync) restored the item between + // our delete and add. One bounded retry — Keychain calls on this queue are + // synchronous, so we cannot loop indefinitely. + forceDeleteExisting(query: baseQuery) + status = SecItemAdd(attributes as CFDictionary, nil) + return status + } + + private func forceDeleteExisting(query: [String: Any]) { var deleteQuery = query deleteQuery[kSecReturnData as String] = nil deleteQuery[kSecReturnAttributes as String] = nil - deleteQuery[kSecMatchLimit as String] = kSecMatchLimitOne + // `kSecAttrSynchronizableAny` matches both local-only and iCloud-synced + // entries; without it the delete would silently miss the opposite-state + // sibling. `SecItemDelete` ignores `kSecMatchLimit` and removes every + // matching entry, which is exactly what we want for an upsert. + deleteQuery[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny SecItemDelete(deleteQuery as CFDictionary) } @@ -434,6 +469,19 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { let currentVersion = item.metadata.keyVersion.map { Int($0) } ?? 0 if currentVersion >= activeVersion { return nil } + // Skip lazy re-encryption for biometry-protected entries. `SecItemUpdate` + // against a biometric Keychain item triggers a *second* Face ID / Touch ID + // prompt to authorize the mutation — even when we only intend to refresh + // the metadata blob. The user already authenticated for the read; queueing + // another prompt would be confusing and would also break flows where the + // caller renders UI between read and the next user gesture. + // + // These items are still upgraded by: + // - the next explicit `setItem` (a full overwrite the caller initiates), or + // - `rotateKeys({ reEncryptEagerly: true })`, where the rotation prompt is + // expected by the caller. + if isBiometricallyProtected(item.metadata.accessControl) { return nil } + let refreshedMetadata = buildMetadata( securityLevel: item.metadata.securityLevel, accessControl: item.metadata.accessControl, @@ -455,6 +503,18 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { ) } + /// True for the access-control policies whose Keychain entries require a + /// biometric (or device-credential fallback) evaluation on every mutation. + /// `devicePasscode` and `none` writes can be refreshed silently. + private func isBiometricallyProtected(_ policy: AccessControl) -> Bool { + switch policy { + case .secureenclavebiometry, .biometrycurrentset, .biometryany: + return true + case .devicepasscode, .none: + return false + } + } + private func reEncryptAll( service: String, request: RotateKeysRequest?, @@ -552,10 +612,12 @@ public final class HybridSensitiveInfo: HybridSensitiveInfoSpec { private func resolveAvailability() -> SecurityAvailability { let capabilities = availabilityResolver.resolve() + let status = BiometryStatus(fromString: capabilities.biometryStatus.rawValue) ?? .unknown return SecurityAvailability( secureEnclave: capabilities.secureEnclave, strongBox: capabilities.strongBox, biometry: capabilities.biometry, + biometryStatus: status, deviceCredential: capabilities.deviceCredential ) } diff --git a/ios/Internal/Security/SecurityAvailabilityResolver.swift b/ios/Internal/Security/SecurityAvailabilityResolver.swift index 3ce326e1..1312c03d 100644 --- a/ios/Internal/Security/SecurityAvailabilityResolver.swift +++ b/ios/Internal/Security/SecurityAvailabilityResolver.swift @@ -1,10 +1,31 @@ import Foundation import LocalAuthentication +/// Detailed biometric availability state mirroring the JS `BiometryStatus` union. +/// +/// String-backed so the value can be passed straight across the Nitro bridge without an extra +/// mapping table. Keep the raw values in sync with `BiometryStatus` in +/// `src/sensitive-info.nitro.ts`. +enum BiometryStatusNative: String { + case available + case notEnrolled + case notAvailable + case lockedOut + case unknown +} + /// Aggregates the current device's authentication capabilities (biometrics, passcode, secure enclave). final class SecurityAvailabilityResolver { + struct Snapshot { + let secureEnclave: Bool + let strongBox: Bool + let biometry: Bool + let biometryStatus: BiometryStatusNative + let deviceCredential: Bool + } + private let lock = NSLock() - private var cached: (secureEnclave: Bool, strongBox: Bool, biometry: Bool, deviceCredential: Bool)? + private var cached: Snapshot? /** Detects which secure hardware features are currently available. @@ -12,7 +33,7 @@ final class SecurityAvailabilityResolver { always rely on this method rather than assuming capabilities. The snapshot is reused across Apple platforms (iOS, macOS, visionOS, watchOS). */ - func resolve() -> (secureEnclave: Bool, strongBox: Bool, biometry: Bool, deviceCredential: Bool) { + func resolve() -> Snapshot { lock.lock() defer { lock.unlock() } @@ -25,15 +46,16 @@ final class SecurityAvailabilityResolver { return snapshot } - private func resolveOnMainThread() -> (secureEnclave: Bool, strongBox: Bool, biometry: Bool, deviceCredential: Bool) { + private func resolveOnMainThread() -> Snapshot { if Thread.isMainThread { return performCapabilityProbe() } - var snapshot: (secureEnclave: Bool, strongBox: Bool, biometry: Bool, deviceCredential: Bool) = ( + var snapshot = Snapshot( secureEnclave: false, strongBox: false, biometry: false, + biometryStatus: .unknown, deviceCredential: false ) @@ -44,11 +66,11 @@ final class SecurityAvailabilityResolver { return snapshot } - private func performCapabilityProbe() -> (secureEnclave: Bool, strongBox: Bool, biometry: Bool, deviceCredential: Bool) { + private func performCapabilityProbe() -> Snapshot { let context = LAContext() - var error: NSError? + var biometryError: NSError? - let supportsBiometry = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + let supportsBiometry = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &biometryError) #if targetEnvironment(simulator) let biometryAvailable = supportsBiometry let secureEnclaveAvailable = supportsBiometry @@ -56,14 +78,54 @@ final class SecurityAvailabilityResolver { let biometryAvailable = supportsBiometry && context.biometryType != .none let secureEnclaveAvailable = biometryAvailable #endif - error = nil - let supportsDeviceCredential = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) - return ( + let biometryStatus = classifyBiometryStatus( + supportsBiometry: supportsBiometry, + biometryAvailable: biometryAvailable, + probeError: biometryError + ) + + var credentialError: NSError? + let supportsDeviceCredential = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &credentialError) + + return Snapshot( secureEnclave: secureEnclaveAvailable, strongBox: false, biometry: biometryAvailable, + biometryStatus: biometryStatus, deviceCredential: supportsDeviceCredential ) } + + private func classifyBiometryStatus( + supportsBiometry: Bool, + biometryAvailable: Bool, + probeError: NSError? + ) -> BiometryStatusNative { + if biometryAvailable { + return .available + } + + guard let error = probeError else { + // No error reported but biometry not available — typically "no hardware" on devices + // (and never reached on simulators since `biometryAvailable == supportsBiometry` there). + return .notAvailable + } + + guard error.domain == LAErrorDomain else { + return .unknown + } + + switch error.code { + case LAError.biometryNotEnrolled.rawValue: + return .notEnrolled + case LAError.biometryLockout.rawValue: + return .lockedOut + case LAError.biometryNotAvailable.rawValue, + LAError.passcodeNotSet.rawValue: + return .notAvailable + default: + return .unknown + } + } } diff --git a/package.json b/package.json index 0be46c3e..616a513f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-sensitive-info", - "version": "6.0.0", + "version": "6.1.0", "description": "🔐 React Native secure storage, rebuilt with Nitro Modules ⚡️ Biometric-ready, StrongBox-aware, and metadata-rich for modern mobile apps", "main": "./lib/commonjs/index.js", "module": "./lib/module/index.js", @@ -145,13 +145,15 @@ [ "commonjs", { - "esm": true + "esm": true, + "configFile": true } ], [ "module", { - "esm": true + "esm": true, + "configFile": true } ], [ diff --git a/src/__tests__/__mocks__/react-native.ts b/src/__tests__/__mocks__/react-native.ts index 39c7543a..8fb6a8dd 100644 --- a/src/__tests__/__mocks__/react-native.ts +++ b/src/__tests__/__mocks__/react-native.ts @@ -1,5 +1,37 @@ +type Listener = (status: string) => void + +const listeners: Listener[] = [] + +export const AppState = { + currentState: 'active' as string, + addEventListener: (event: 'change', listener: Listener) => { + if (event !== 'change') { + throw new Error(`unsupported AppState event: ${event}`) + } + listeners.push(listener) + return { + remove: () => { + const idx = listeners.indexOf(listener) + if (idx >= 0) listeners.splice(idx, 1) + }, + } + }, + __emit: (status: string) => { + AppState.currentState = status + for (const l of [...listeners]) l(status) + }, + __listenerCount: () => listeners.length, + __reset: () => { + listeners.length = 0 + AppState.currentState = 'active' + }, +} + +export type AppStateStatus = 'active' | 'background' | 'inactive' | 'unknown' + export const NativeModules = {} export default { + AppState, NativeModules, } diff --git a/src/__tests__/core.access-control.test.ts b/src/__tests__/core.access-control.test.ts new file mode 100644 index 00000000..309e28f3 --- /dev/null +++ b/src/__tests__/core.access-control.test.ts @@ -0,0 +1,163 @@ +import { + canUseAccessControl, + canUseAccessControlSync, +} from '../core/access-control' +import { getSupportedSecurityLevels } from '../core/storage' +import type { + AccessControl, + BiometryStatus, + SecurityAvailability, +} from '../sensitive-info.nitro' + +jest.mock('../core/storage', () => ({ + ...jest.requireActual('../core/storage'), + getSupportedSecurityLevels: jest.fn(), +})) + +const mockedGet = getSupportedSecurityLevels as jest.MockedFunction< + typeof getSupportedSecurityLevels +> + +const buildSnapshot = ( + overrides: Partial = {} +): SecurityAvailability => ({ + secureEnclave: true, + strongBox: false, + biometry: true, + biometryStatus: 'available', + deviceCredential: true, + ...overrides, +}) + +const ALL_STATUSES: readonly BiometryStatus[] = [ + 'available', + 'notEnrolled', + 'notAvailable', + 'lockedOut', + 'unknown', +] + +const ALL_POLICIES: readonly AccessControl[] = [ + 'secureEnclaveBiometry', + 'biometryCurrentSet', + 'biometryAny', + 'devicePasscode', + 'none', +] + +describe('biometryStatus invariant', () => { + it.each( + ALL_STATUSES + )('biometry === (biometryStatus === "available") for status=%s', (status) => { + const snapshot = buildSnapshot({ + biometryStatus: status, + biometry: status === 'available', + }) + expect(snapshot.biometry).toBe(snapshot.biometryStatus === 'available') + }) +}) + +describe('canUseAccessControlSync — policy × status mapping', () => { + const cases: readonly [ + AccessControl, + BiometryStatus, + Partial, + boolean, + ][] = [ + // secureEnclaveBiometry: requires (secureEnclave || strongBox) AND biometry available + ['secureEnclaveBiometry', 'available', { secureEnclave: true }, true], + [ + 'secureEnclaveBiometry', + 'available', + { secureEnclave: false, strongBox: true }, + true, + ], + [ + 'secureEnclaveBiometry', + 'available', + { secureEnclave: false, strongBox: false }, + false, + ], + ['secureEnclaveBiometry', 'notEnrolled', { secureEnclave: true }, false], + ['secureEnclaveBiometry', 'notAvailable', { secureEnclave: true }, false], + ['secureEnclaveBiometry', 'lockedOut', { secureEnclave: true }, false], + ['secureEnclaveBiometry', 'unknown', { secureEnclave: true }, false], + + // biometryCurrentSet: biometry available + ['biometryCurrentSet', 'available', {}, true], + ['biometryCurrentSet', 'notEnrolled', {}, false], + ['biometryCurrentSet', 'notAvailable', {}, false], + ['biometryCurrentSet', 'lockedOut', {}, false], + ['biometryCurrentSet', 'unknown', {}, false], + + // biometryAny: biometry available + ['biometryAny', 'available', {}, true], + ['biometryAny', 'notEnrolled', {}, false], + ['biometryAny', 'notAvailable', {}, false], + ['biometryAny', 'lockedOut', {}, false], + ['biometryAny', 'unknown', {}, false], + + // devicePasscode: deviceCredential + ['devicePasscode', 'available', { deviceCredential: true }, true], + ['devicePasscode', 'available', { deviceCredential: false }, false], + ['devicePasscode', 'notAvailable', { deviceCredential: true }, true], + ['devicePasscode', 'notAvailable', { deviceCredential: false }, false], + + // none: always true + ...ALL_STATUSES.map( + (s) => + [ + 'none', + s, + { secureEnclave: false, strongBox: false, deviceCredential: false }, + true, + ] as [ + AccessControl, + BiometryStatus, + Partial, + boolean, + ] + ), + ] + + it.each( + cases + )('%s + status=%s + overrides=%j -> %s', (policy, status, overrides, expected) => { + const snapshot = buildSnapshot({ + ...overrides, + biometryStatus: status, + biometry: status === 'available', + }) + expect(canUseAccessControlSync(policy, snapshot)).toBe(expected) + }) + + it('covers every policy at least once', () => { + const seen = new Set(cases.map(([p]) => p)) + for (const p of ALL_POLICIES) expect(seen).toContain(p) + }) +}) + +describe('canUseAccessControl — async wrapper', () => { + beforeEach(() => mockedGet.mockReset()) + + it('fetches the snapshot when not provided', async () => { + mockedGet.mockResolvedValueOnce( + buildSnapshot({ biometryStatus: 'available' }) + ) + await expect(canUseAccessControl('secureEnclaveBiometry')).resolves.toBe( + true + ) + expect(mockedGet).toHaveBeenCalledTimes(1) + }) + + it('skips the native call when a snapshot is provided', async () => { + const snapshot = buildSnapshot({ + biometryStatus: 'notEnrolled', + biometry: false, + }) + await expect( + canUseAccessControl('biometryCurrentSet', snapshot) + ).resolves.toBe(false) + expect(mockedGet).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/core.storage.test.ts b/src/__tests__/core.storage.test.ts index e0cbb9f8..aa3f6c87 100644 --- a/src/__tests__/core.storage.test.ts +++ b/src/__tests__/core.storage.test.ts @@ -239,6 +239,7 @@ describe('core/storage', () => { secureEnclave: true, strongBox: true, biometry: true, + biometryStatus: 'available', deviceCredential: false, }) @@ -248,6 +249,7 @@ describe('core/storage', () => { secureEnclave: true, strongBox: true, biometry: true, + biometryStatus: 'available', deviceCredential: false, }) expect(nativeHandle.getSupportedSecurityLevels).toHaveBeenCalled() diff --git a/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx b/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx new file mode 100644 index 00000000..2e810b9c --- /dev/null +++ b/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx @@ -0,0 +1,86 @@ +import { waitFor } from '@testing-library/dom' +import { act, renderHook } from '@testing-library/react' +import { AppState } from 'react-native' +import { getSupportedSecurityLevels } from '../core/storage' +import { useBiometryStatusWatcher } from '../hooks/useBiometryStatusWatcher' +import type { BiometryStatus } from '../sensitive-info.nitro' + +jest.mock('../core/storage', () => ({ + ...jest.requireActual('../core/storage'), + getSupportedSecurityLevels: jest.fn(), +})) + +const mockedGet = getSupportedSecurityLevels as jest.MockedFunction< + typeof getSupportedSecurityLevels +> + +const appState = AppState as unknown as { + __emit: (status: string) => void + __reset: () => void +} + +const buildSnapshot = (status: BiometryStatus) => ({ + secureEnclave: true, + strongBox: false, + biometry: status === 'available', + biometryStatus: status, + deviceCredential: true, +}) + +describe('useBiometryStatusWatcher', () => { + beforeEach(() => { + mockedGet.mockReset() + appState.__reset() + }) + + it('fires onChange once on initial detection with previous=null', async () => { + mockedGet.mockResolvedValue(buildSnapshot('available')) + const onChange = jest.fn() + + renderHook(() => useBiometryStatusWatcher(onChange)) + + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)) + expect(onChange).toHaveBeenCalledWith('available', null) + }) + + it('fires only on actual transitions, not on every refetch', async () => { + mockedGet + .mockResolvedValueOnce(buildSnapshot('notEnrolled')) + .mockResolvedValueOnce(buildSnapshot('notEnrolled')) // same status -> no fire + .mockResolvedValueOnce(buildSnapshot('available')) + + const onChange = jest.fn() + renderHook(() => useBiometryStatusWatcher(onChange)) + + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1)) + expect(onChange).toHaveBeenLastCalledWith('notEnrolled', null) + + // Foreground refresh keeping the same status: should NOT fire. + await act(async () => { + appState.__emit('background') + appState.__emit('active') + }) + await waitFor(() => expect(mockedGet).toHaveBeenCalledTimes(2)) + expect(onChange).toHaveBeenCalledTimes(1) + + // Foreground refresh that flips status: should fire with previous=notEnrolled. + // Skip past the 500 ms debounce by advancing the clock instead of sleeping + // — the debounce uses `Date.now()`, not a queued timer, so a `Date.now` + // spy is enough and keeps the test deterministic. + const realNow = Date.now + const nowSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => realNow() + 1_000) + try { + await act(async () => { + appState.__emit('background') + appState.__emit('active') + }) + + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)) + expect(onChange).toHaveBeenLastCalledWith('available', 'notEnrolled') + } finally { + nowSpy.mockRestore() + } + }) +}) diff --git a/src/__tests__/hooks.useSecurityAvailability.test.tsx b/src/__tests__/hooks.useSecurityAvailability.test.tsx index 48077c11..f27d7d23 100644 --- a/src/__tests__/hooks.useSecurityAvailability.test.tsx +++ b/src/__tests__/hooks.useSecurityAvailability.test.tsx @@ -1,5 +1,6 @@ import { waitFor } from '@testing-library/dom' import { act, renderHook } from '@testing-library/react' +import { AppState } from 'react-native' import { getSupportedSecurityLevels } from '../core/storage' import { HookError } from '../hooks/types' import { useSecurityAvailability } from '../hooks/useSecurityAvailability' @@ -17,6 +18,7 @@ const mockedGetSupportedSecurityLevels = describe('useSecurityAvailability', () => { beforeEach(() => { mockedGetSupportedSecurityLevels.mockReset() + ;(AppState as unknown as { __reset: () => void }).__reset() }) it('loads and caches the security capabilities', async () => { @@ -24,6 +26,7 @@ describe('useSecurityAvailability', () => { secureEnclave: true, strongBox: false, biometry: true, + biometryStatus: 'available', deviceCredential: true, }) @@ -35,6 +38,7 @@ describe('useSecurityAvailability', () => { secureEnclave: true, strongBox: false, biometry: true, + biometryStatus: 'available', deviceCredential: true, }) expect(result.current.error).toBeNull() @@ -63,12 +67,14 @@ describe('useSecurityAvailability', () => { secureEnclave: true, strongBox: false, biometry: true, + biometryStatus: 'available', deviceCredential: true, }) .mockResolvedValueOnce({ secureEnclave: false, strongBox: true, biometry: true, + biometryStatus: 'available', deviceCredential: true, }) @@ -84,4 +90,88 @@ describe('useSecurityAvailability', () => { await waitFor(() => expect(result.current.data?.strongBox).toBe(true)) expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(2) }) + + describe('refreshOnForeground', () => { + const appState = AppState as unknown as { + __emit: (status: string) => void + __listenerCount: () => number + } + + const baseSnapshot = { + secureEnclave: true, + strongBox: false, + biometry: false, + biometryStatus: 'notEnrolled' as const, + deviceCredential: true, + } + + it('does not subscribe to AppState by default', async () => { + mockedGetSupportedSecurityLevels.mockResolvedValue(baseSnapshot) + const { unmount } = renderHook(() => useSecurityAvailability()) + await waitFor(() => + expect(mockedGetSupportedSecurityLevels).toHaveBeenCalled() + ) + expect(appState.__listenerCount()).toBe(0) + unmount() + }) + + it('refetches when the app returns to active', async () => { + mockedGetSupportedSecurityLevels + .mockResolvedValueOnce(baseSnapshot) + .mockResolvedValueOnce({ + ...baseSnapshot, + biometry: true, + biometryStatus: 'available', + }) + + const { result } = renderHook(() => + useSecurityAvailability({ refreshOnForeground: true }) + ) + + await waitFor(() => + expect(result.current.data?.biometryStatus).toBe('notEnrolled') + ) + expect(appState.__listenerCount()).toBe(1) + + await act(async () => { + appState.__emit('background') + appState.__emit('active') + }) + + await waitFor(() => + expect(result.current.data?.biometryStatus).toBe('available') + ) + expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(2) + }) + + it('debounces back-to-back active transitions', async () => { + mockedGetSupportedSecurityLevels.mockResolvedValue(baseSnapshot) + + renderHook(() => useSecurityAvailability({ refreshOnForeground: true })) + await waitFor(() => + expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(1) + ) + + await act(async () => { + appState.__emit('active') + appState.__emit('active') + appState.__emit('active') + }) + + // Initial fetch + at most one debounced refetch. + expect( + mockedGetSupportedSecurityLevels.mock.calls.length + ).toBeLessThanOrEqual(2) + }) + + it('removes the AppState subscription on unmount', async () => { + mockedGetSupportedSecurityLevels.mockResolvedValue(baseSnapshot) + const { unmount } = renderHook(() => + useSecurityAvailability({ refreshOnForeground: true }) + ) + await waitFor(() => expect(appState.__listenerCount()).toBe(1)) + unmount() + expect(appState.__listenerCount()).toBe(0) + }) + }) }) diff --git a/src/core/access-control.ts b/src/core/access-control.ts new file mode 100644 index 00000000..67b92aa6 --- /dev/null +++ b/src/core/access-control.ts @@ -0,0 +1,95 @@ +import type { + AccessControl, + SecurityAvailability, +} from '../sensitive-info.nitro' +import { getSupportedSecurityLevels } from './storage' + +/** + * Pure mapping table: which capability does each {@link AccessControl} policy require? + * + * Kept as a `const` literal lookup (not a `switch`, not a `Map`) so the minifier can constant-fold + * it and dead-code-eliminate unused branches. Internal helper — not exported from the public API. + * + * @internal + */ +const POLICY_PREDICATES: { + readonly [P in AccessControl]: (levels: SecurityAvailability) => boolean +} = { + secureEnclaveBiometry: (l) => + (l.secureEnclave || l.strongBox) && l.biometryStatus === 'available', + biometryCurrentSet: (l) => l.biometryStatus === 'available', + biometryAny: (l) => l.biometryStatus === 'available', + devicePasscode: (l) => l.deviceCredential, + none: () => true, +} + +/** + * Synchronous variant of {@link canUseAccessControl} for callers that already hold a + * {@link SecurityAvailability} snapshot (e.g. inside a render that consumes + * {@link useSecurityAvailability}). + * + * Pure function — no native call, no IPC round-trip. Safe to call inside React render paths. + * + * @param policy - The {@link AccessControl} policy you intend to write with. + * @param levels - Capability snapshot, typically from {@link getSupportedSecurityLevels} or + * {@link useSecurityAvailability}. + * @returns `true` when the device currently satisfies the policy's requirements; `false` when a + * write would fail or be silently downgraded. + * + * @example + * ```tsx + * const { data: caps } = useSecurityAvailability() + * const canEnable = caps ? canUseAccessControlSync('secureEnclaveBiometry', caps) : false + * return + * ``` + * + * @see {@link canUseAccessControl} + * @public + */ +export function canUseAccessControlSync( + policy: AccessControl, + levels: SecurityAvailability +): boolean { + return POLICY_PREDICATES[policy](levels) +} + +/** + * Predicts whether a given {@link AccessControl} policy can be satisfied on the current device + * **right now** — useful for gating biometric toggles before attempting a write that would + * otherwise fail or be silently downgraded. + * + * Internally maps the requested policy onto the {@link SecurityAvailability} snapshot returned by + * {@link getSupportedSecurityLevels}. Pass `levels` to skip the native round-trip. + * + * @param policy - The {@link AccessControl} policy you intend to write with. + * @param levels - Optional pre-fetched capability snapshot. When omitted, the helper calls + * {@link getSupportedSecurityLevels} internally. + * @returns Resolves to `true` when the policy can be applied, `false` otherwise. Resolves to + * `false` (rather than throwing) for `'unknown'` biometry status — gate UI off availability + * instead. + * + * @remarks + * This is a *predictive* check, not a guarantee — the user could change biometric settings + * between this call and the subsequent write. Always handle {@link KeyInvalidatedError} and + * {@link AuthenticationCanceledError} on the write path as well. + * + * @example + * ```ts + * if (await canUseAccessControl('secureEnclaveBiometry')) { + * await setItem('session', token, { accessControl: 'secureEnclaveBiometry' }) + * } else { + * await setItem('session', token, { accessControl: 'devicePasscode' }) + * } + * ``` + * + * @see {@link canUseAccessControlSync} + * @see {@link getSupportedSecurityLevels} + * @public + */ +export async function canUseAccessControl( + policy: AccessControl, + levels?: SecurityAvailability +): Promise { + const snapshot = levels ?? (await getSupportedSecurityLevels()) + return canUseAccessControlSync(policy, snapshot) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0e735f77..6d69f86e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,6 +11,11 @@ export { type HookSuccessResult, type VoidAsyncState, } from './types' +export { + type BiometryStatusChangeListener, + type UseBiometryStatusWatcherResult, + useBiometryStatusWatcher, +} from './useBiometryStatusWatcher' export { type UseHasSecretOptions, type UseHasSecretResult, @@ -41,6 +46,7 @@ export { useSecureStorage, } from './useSecureStorage' export { + type UseSecurityAvailabilityOptions, type UseSecurityAvailabilityResult, useSecurityAvailability, } from './useSecurityAvailability' diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts index 8597da37..bba826ef 100644 --- a/src/hooks/useAsync.ts +++ b/src/hooks/useAsync.ts @@ -79,6 +79,10 @@ export default function useAsync( readonly preserveDataOnError?: boolean | undefined } = {} ): UseAsyncResult { + 'use no memo' + // Intentional opt-out: the body uses optional chaining inside a try/catch + // (a value-block pattern the React Compiler does not yet support). The + // inner reducer + memoized callbacks already minimise re-renders. const { hint, skip = false, preserveDataOnError = false } = options const [state, dispatch] = useReducer( reducer as (state: AsyncState, action: AsyncAction) => AsyncState, diff --git a/src/hooks/useBiometryStatusWatcher.ts b/src/hooks/useBiometryStatusWatcher.ts new file mode 100644 index 00000000..693fd538 --- /dev/null +++ b/src/hooks/useBiometryStatusWatcher.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' +import type { BiometryStatus } from '../sensitive-info.nitro' +import { + type UseSecurityAvailabilityResult, + useSecurityAvailability, +} from './useSecurityAvailability' + +/** + * Callback invoked when {@link BiometryStatus} transitions between distinct values. + * + * @param next - The new status reported by the native probe. + * @param previous - The status observed on the previous render, or `null` on first detection. + * + * @public + */ +export type BiometryStatusChangeListener = ( + next: BiometryStatus, + previous: BiometryStatus | null +) => void + +/** + * Result returned by {@link useBiometryStatusWatcher}. + * + * Re-exposes the underlying {@link UseSecurityAvailabilityResult} so callers can render UI off + * the same snapshot without composing two hooks. + * + * @public + */ +export type UseBiometryStatusWatcherResult = UseSecurityAvailabilityResult + +/** + * Subscribes to biometric availability and fires `onChange` **only** when + * {@link BiometryStatus} actually transitions. + * + * Internally wraps {@link useSecurityAvailability} with `refreshOnForeground: true`, so the + * status is re-probed whenever the app returns to foreground. The callback is invoked only on + * real transitions (e.g. `'notEnrolled'` → `'available'` after the user enrolls a fingerprint), + * never on every render or refetch. + * + * Lives in its own module so apps that don't watch enrollment changes don't pay for it (Metro / + * Webpack tree-shaking via `sideEffects: false`). + * + * @param onChange - Listener invoked on each status transition. Stable across renders is + * recommended (e.g. `useCallback`) — the watcher captures the latest reference automatically. + * @returns The same {@link UseSecurityAvailabilityResult} you'd get from + * {@link useSecurityAvailability}, so you can render UI without composing two hooks. + * + * @example + * ```tsx + * useBiometryStatusWatcher((next, prev) => { + * analytics.track('biometry_status_changed', { from: prev, to: next }) + * if (next === 'available' && prev === 'notEnrolled') showToast('Face ID is ready.') + * }) + * ``` + * + * @see {@link useSecurityAvailability} + * @see {@link BiometryStatus} + * @public + */ +export function useBiometryStatusWatcher( + onChange: BiometryStatusChangeListener +): UseBiometryStatusWatcherResult { + const result = useSecurityAvailability({ refreshOnForeground: true }) + + // Capture the latest listener so subscribers don't need to memoize it. + const listenerRef = useRef(onChange) + useEffect(() => { + listenerRef.current = onChange + }, [onChange]) + + const previousRef = useRef(null) + const status = result.data?.biometryStatus ?? null + + useEffect(() => { + if (status === null) return + const previous = previousRef.current + if (previous === status) return + previousRef.current = status + listenerRef.current(status, previous) + }, [status]) + + return result +} diff --git a/src/hooks/useMutation.ts b/src/hooks/useMutation.ts index 0e55f287..48eabaeb 100644 --- a/src/hooks/useMutation.ts +++ b/src/hooks/useMutation.ts @@ -95,6 +95,10 @@ const useMutation = ( defaultOperation: string, defaultHint: string ): UseMutationResult => { + 'use no memo' + // Intentional opt-out: the mutate path uses optional chaining inside + // try/catch (a value-block pattern the React Compiler does not yet + // support). Manual memoization below is correct. const [state, dispatch] = useReducer(reducer, IDLE) const { begin, mountedRef } = useAsyncLifecycle() diff --git a/src/hooks/useSecureStorage.ts b/src/hooks/useSecureStorage.ts index 939d1a01..a580586a 100644 --- a/src/hooks/useSecureStorage.ts +++ b/src/hooks/useSecureStorage.ts @@ -103,6 +103,11 @@ export interface UseSecureStorageResult { export function useSecureStorage( options?: UseSecureStorageOptions ): UseSecureStorageResult { + 'use no memo' + // Intentional opt-out: this hook coordinates several refs (cache, abort, + // pending mutation) during render to keep the public API stable across + // option-object identity changes — a pattern the React Compiler cannot + // preserve without losing the deep-equality guarantees we ship. const fetchRunner = useCallback( (request: SensitiveInfoOptions) => getAllItems(request), [] diff --git a/src/hooks/useSecurityAvailability.ts b/src/hooks/useSecurityAvailability.ts index 586fc162..d8ea531a 100644 --- a/src/hooks/useSecurityAvailability.ts +++ b/src/hooks/useSecurityAvailability.ts @@ -1,17 +1,49 @@ -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { AppState, type AppStateStatus } from 'react-native' import { getSupportedSecurityLevels } from '../core/storage' import type { SecurityAvailability } from '../sensitive-info.nitro' import type { AsyncState } from './types' import useAsync from './useAsync' +/** + * Tunables for {@link useSecurityAvailability}. + * + * @public + */ +export interface UseSecurityAvailabilityOptions { + /** + * When `true`, automatically calls {@link UseSecurityAvailabilityResult.refetch} whenever the + * app transitions to the `active` state. Recommended for screens that gate UI on biometric + * enrollment — covers the common flow where the user leaves to system settings, enrolls/removes + * a biometric, and returns to the app. + * + * @remarks Back-to-back `active` transitions within ~500 ms are debounced to avoid double + * fetches when iOS briefly inactivates the app for system overlays (Control Center, Face ID + * sheet, etc.). Listener is detached on unmount. + * + * @defaultValue `false` + */ + readonly refreshOnForeground?: boolean +} + +/** + * Result returned by {@link useSecurityAvailability}. + * + * @public + */ export interface UseSecurityAvailabilityResult extends AsyncState { + /** Forces a fresh native call, bypassing the per-instance cache. */ readonly refetch: () => Promise } +const FOREGROUND_DEBOUNCE_MS = 500 + /** * Queries which security primitives are available on the current device and caches the outcome. * + * @param options - Optional {@link UseSecurityAvailabilityOptions}; pass + * `{ refreshOnForeground: true }` to auto-refresh when the app returns to foreground. * @returns A {@link UseSecurityAvailabilityResult} with `data` (the latest snapshot), * `error`/`isLoading`/`isPending` flags, and a `refetch` helper that bypasses the cache. * @@ -23,20 +55,28 @@ export interface UseSecurityAvailabilityResult * in system settings. * - On error, the previously cached `data` is preserved so you can render fallback UI without * losing capability info. + * - When `refreshOnForeground` is enabled, the hook subscribes to `AppState` and refetches on + * `active` transitions (debounced). The subscription is created lazily inside `useEffect` so + * the hook remains tree-shakable and side-effect-free at the module level. * * @example * ```tsx - * const { data: caps, isLoading } = useSecurityAvailability() + * const { data: caps, isLoading } = useSecurityAvailability({ refreshOnForeground: true }) * * if (isLoading || !caps) return null + * if (caps.biometryStatus === 'notEnrolled') return * return caps.biometry * ? * : Biometrics unavailable on this device. * ``` * * @see {@link getSupportedSecurityLevels} + * @see {@link useBiometryStatusWatcher} + * @public */ -export function useSecurityAvailability(): UseSecurityAvailabilityResult { +export function useSecurityAvailability( + options?: UseSecurityAvailabilityOptions +): UseSecurityAvailabilityResult { const cacheRef = useRef(null) const forceRef = useRef(false) @@ -60,7 +100,26 @@ export function useSecurityAvailability(): UseSecurityAvailabilityResult { const refetch = useCallback(async () => { forceRef.current = true await inner.refetch() - }, [inner.refetch]) + // Depend on the whole `inner` object: the React Compiler infers `inner` as + // the dependency and a more specific `[inner.refetch]` would prevent it + // from preserving this memoization. + }, [inner]) + + const refreshOnForeground = options?.refreshOnForeground === true + const lastRefreshRef = useRef(0) + + useEffect(() => { + if (!refreshOnForeground) return undefined + const handleChange = (next: AppStateStatus) => { + if (next !== 'active') return + const now = Date.now() + if (now - lastRefreshRef.current < FOREGROUND_DEBOUNCE_MS) return + lastRefreshRef.current = now + void refetch() + } + const subscription = AppState.addEventListener('change', handleChange) + return () => subscription.remove() + }, [refreshOnForeground, refetch]) return useMemo( () => ({ diff --git a/src/hooks/useStableOptions.ts b/src/hooks/useStableOptions.ts index 51347324..0c1d838b 100644 --- a/src/hooks/useStableOptions.ts +++ b/src/hooks/useStableOptions.ts @@ -33,6 +33,11 @@ const useStableOptions = ( defaults: Partial, options?: Partial | null ): T => { + 'use no memo' + // Intentional opt-out: this hook reads multiple refs during render to compute + // a structurally-stable identity — a pattern the React Compiler cannot + // preserve. The whole point of the hook is to short-circuit re-derivation + // across renders, so manual memoization is the load-bearing implementation. const cachedDefaultsRef = useRef | null>(null) const cachedOptionsRef = useRef | null | undefined>(undefined) const valueRef = useRef(null) diff --git a/src/index.ts b/src/index.ts index 02ffef76..7243e8ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,10 @@ * @see {@link SensitiveInfoError} */ +export { + canUseAccessControl, + canUseAccessControlSync, +} from './core/access-control' export { clearService, deleteItem, @@ -83,6 +87,7 @@ export { export type { AccessControl, AuthenticationPrompt, + BiometryStatus, MutationResult, RotateKeysRequest, RotationResult, diff --git a/src/sensitive-info.nitro.ts b/src/sensitive-info.nitro.ts index 03074923..728e6af6 100644 --- a/src/sensitive-info.nitro.ts +++ b/src/sensitive-info.nitro.ts @@ -244,6 +244,48 @@ export interface MutationResult { readonly metadata: StorageMetadata } +/** + * Fine-grained biometric availability state. + * + * Disambiguates the three UX-distinct outcomes a single boolean cannot express: "hardware is + * missing", "hardware is present but the user has not enrolled a biometric", and "ready to use". + * Drive feature toggles and onboarding CTAs off this field — for example, render a *"Set up + * Face ID in Settings"* deep-link when the value is `'notEnrolled'` instead of hiding the toggle + * entirely. + * + * String-literal union (no TypeScript `enum`) so comparisons narrow correctly, the values are + * fully tree-shakable, and zero runtime objects are emitted. + * + * @see {@link SecurityAvailability.biometryStatus} + * @see {@link SecurityAvailability.biometry} + */ +export type BiometryStatus = + /** Hardware present, at least one biometric enrolled, currently usable. */ + | 'available' + /** + * Hardware present but no fingerprint/face is registered. Surface a *"Set up Face ID / fingerprint"* + * CTA that deep-links to system settings instead of hiding the toggle. + */ + | 'notEnrolled' + /** + * Biometric hardware is missing or permanently disabled (administrator policy, hardware fault, + * passcode not set on iOS). Hide the toggle entirely. + */ + | 'notAvailable' + /** + * Too many recent failed attempts have temporarily locked biometrics. Render a *"Try again later"* + * affordance and consider falling back to `devicePasscode`. + * + * @remarks Currently surfaced from `LAError.biometryLockout` on iOS. On Android, transient lockout + * is reported via `BiometricPrompt` failure paths rather than {@link getSupportedSecurityLevels}. + */ + | 'lockedOut' + /** + * The capability probe could not classify the device. Treat as `'notAvailable'` for gating + * purposes; the value exists to keep the union forward-compatible. + */ + | 'unknown' + /** * Snapshot of the secure hardware capabilities currently exposed to the runtime. * @@ -251,12 +293,28 @@ export interface MutationResult { * `true`) instead of attempting writes that will fail at runtime. */ export interface SecurityAvailability { - /** Apple Secure Enclave is present and addressable. **iOS/macOS only** — always `false` on Android. */ + /** + * A hardware-isolated key store is present and addressable. On iOS/macOS this maps to the + * Apple Secure Enclave; on Android it mirrors {@link strongBox} (true when StrongBox is + * available), so a single boolean lets cross-platform code gate "use hardware-backed keys" + * UX without branching on `Platform.OS`. + */ readonly secureEnclave: boolean /** Android StrongBox is present. **Android only** — always `false` on iOS. */ readonly strongBox: boolean - /** At least one biometric is enrolled and available for authentication. */ + /** + * Convenience boolean equal to `biometryStatus === 'available'`. Kept for backward + * compatibility — prefer {@link biometryStatus} for nuanced UX gating (e.g. distinguishing + * "not enrolled" from "no hardware"). + */ readonly biometry: boolean + /** + * Detailed biometric availability state. See {@link BiometryStatus} for the per-value contract. + * + * @remarks Invariant: `biometry === (biometryStatus === 'available')`. Both fields are populated + * by the same native probe. + */ + readonly biometryStatus: BiometryStatus /** Device has a credential set (passcode/PIN/pattern). */ readonly deviceCredential: boolean } diff --git a/yarn.lock b/yarn.lock index 762de152..6c01406d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11767,6 +11767,7 @@ __metadata: "@react-native/typescript-config": "npm:0.85.2" "@types/jest": "npm:^30.0.0" babel-plugin-module-resolver: "npm:^5.0.3" + babel-plugin-react-compiler: "npm:^1.0.0" react: "npm:19.2.3" react-native: "npm:0.85.2" react-native-nitro-modules: "npm:0.35.5"