From 92bc4e09342f4286a02c77a3f0321115bf8189d8 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Apr 2026 14:25:58 -0300 Subject: [PATCH 1/6] Add biometryStatus, policy precheck & watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce fine-grained biometry detection and related UX helpers. Adds a new biometryStatus enum to SecurityAvailability (available | notEnrolled | notAvailable | lockedOut | unknown) while keeping the legacy biometry boolean as an alias. Wire biometryStatus through native probes on Android and iOS, add classify logic on both platforms, and surface it to JS. Provide policy precheck helpers: canUseAccessControl and canUseAccessControlSync (pure TS mapping over SecurityAvailability) so callers can predict whether a given AccessControl will succeed without a native round-trip. Add refreshOnForeground option to useSecurityAvailability to auto-refetch on app foreground (debounced), and introduce useBiometryStatusWatcher — a transition-only hook that fires only on real biometry status changes. Also: update docs and README (biometrics section), example app (BiometryStatusCard + App), diagnostics UI, tests (unit and hook tests, mocks for AppState), and exports. Changes are non-breaking for consumers that continue to use the biometry boolean. --- CHANGELOG.md | 11 + README.md | 71 ++++++- .../com/sensitiveinfo/HybridSensitiveInfo.kt | 1 + .../crypto/SecurityAvailabilityResolver.kt | 41 +++- docs/HOOKS.md | 68 ++++++- example/babel.config.js | 4 + example/package.json | 3 +- example/src/App.tsx | 2 + example/src/components/BiometryStatusCard.tsx | 189 ++++++++++++++++++ example/src/components/DiagnosticsCard.tsx | 1 + ios/HybridSensitiveInfo.swift | 2 + .../SecurityAvailabilityResolver.swift | 82 +++++++- package.json | 6 +- src/__tests__/__mocks__/react-native.ts | 32 +++ src/__tests__/core.access-control.test.ts | 163 +++++++++++++++ src/__tests__/core.storage.test.ts | 2 + .../hooks.useBiometryStatusWatcher.test.tsx | 77 +++++++ .../hooks.useSecurityAvailability.test.tsx | 90 +++++++++ src/core/access-control.ts | 95 +++++++++ src/hooks/index.ts | 6 + src/hooks/useBiometryStatusWatcher.ts | 83 ++++++++ src/hooks/useSecurityAvailability.ts | 62 +++++- src/index.ts | 5 + src/sensitive-info.nitro.ts | 55 ++++- yarn.lock | 1 + 25 files changed, 1121 insertions(+), 31 deletions(-) create mode 100644 example/src/components/BiometryStatusCard.tsx create mode 100644 src/__tests__/core.access-control.test.ts create mode 100644 src/__tests__/hooks.useBiometryStatusWatcher.test.tsx create mode 100644 src/core/access-control.ts create mode 100644 src/hooks/useBiometryStatusWatcher.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eccd138e..d0adaa89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased + +### Added + +* **biometric availability:** New `biometryStatus` field on `SecurityAvailability` (`'available' | 'notEnrolled' | 'notAvailable' | 'lockedOut' | 'unknown'`) disambiguates *hardware missing*, *hardware present but no enrollment*, and *currently usable*. The legacy `biometry` boolean stays as a backward-compatible alias for `biometryStatus === 'available'`. Mapped natively from `LAError` codes on iOS and `BiometricManager.canAuthenticate` results on Android. +* **policy precheck:** New `canUseAccessControl(policy, levels?)` and `canUseAccessControlSync(policy, levels)` predict whether a given `AccessControl` policy will succeed on the current device. Pure TS mapping over `SecurityAvailability` — no extra IPC round-trip. +* **foreground auto-refresh:** `useSecurityAvailability({ refreshOnForeground: true })` subscribes to `AppState` and refetches on `active` transitions (debounced ~500 ms, unsubscribes on unmount). Covers the *user leaves to enroll a fingerprint and returns* flow without manual `refetch()`. +* **enrollment listener:** New `useBiometryStatusWatcher(onChange)` hook fires only on actual `BiometryStatus` transitions (not on every render or refetch). Lives in its own module for tree-shaking. + +All additions are non-breaking; apps reading only the `biometry` boolean continue to work unchanged. + ## [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. diff --git a/README.md b/README.md index ec66a022..6d141548 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 the {@link SecurityAvailability} snapshot, no extra native 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..924e21c4 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 ) } 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/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/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..3e6a8a36 100755 --- a/ios/HybridSensitiveInfo.swift +++ b/ios/HybridSensitiveInfo.swift @@ -552,10 +552,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..dc6d7015 100644 --- a/package.json +++ b/package.json @@ -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..775c01fd --- /dev/null +++ b/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx @@ -0,0 +1,77 @@ +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. + await act(async () => { + // Wait past the 500 ms debounce window before emitting again. + await new Promise((r) => setTimeout(r, 600)) + appState.__emit('background') + appState.__emit('active') + }) + + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)) + expect(onChange).toHaveBeenLastCalledWith('available', 'notEnrolled') + }) +}) 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/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/useSecurityAvailability.ts b/src/hooks/useSecurityAvailability.ts index 586fc162..2bff7f27 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) @@ -62,6 +102,22 @@ export function useSecurityAvailability(): UseSecurityAvailabilityResult { await inner.refetch() }, [inner.refetch]) + 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( () => ({ data: inner.data, 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..45bc1ce1 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. * @@ -255,8 +297,19 @@ export interface SecurityAvailability { 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" From a095da164c82cffbe201b6fe467837e032a8d17e Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Apr 2026 14:31:50 -0300 Subject: [PATCH 2/6] Refactor hooks and babel config to optimize memoization and improve compatibility with React Compiler Co-authored-by: Copilot --- babel.config.js | 38 +++++++++++++++++++++++++--- src/hooks/useAsync.ts | 4 +++ src/hooks/useMutation.ts | 4 +++ src/hooks/useSecureStorage.ts | 5 ++++ src/hooks/useSecurityAvailability.ts | 5 +++- src/hooks/useStableOptions.ts | 5 ++++ 6 files changed, 57 insertions(+), 4 deletions(-) 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/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/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 2bff7f27..d8ea531a 100644 --- a/src/hooks/useSecurityAvailability.ts +++ b/src/hooks/useSecurityAvailability.ts @@ -100,7 +100,10 @@ export function useSecurityAvailability( 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) 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) From 5476572a8937612cf35712dd90ca59dfb23d59a6 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Apr 2026 17:03:33 -0300 Subject: [PATCH 3/6] Skip biometric lazy re-encrypt; robust iOS upsert Avoids a second biometric prompt by skipping lazy re-encryption for entries that require biometric/user authentication (Android and iOS). Adds helpers (requiresBiometricAuth / isBiometricallyProtected) to detect such entries so upgrades only occur via explicit setItem or eager rotateKeys. Replaces direct SecItemAdd/delete flows with upsertKeychainEntry + forceDeleteExisting on iOS to wipe any synchronizable sibling (uses kSecAttrSynchronizableAny) and absorb iCloud restore races with a single bounded retry, preventing errSecDuplicateItem when iosSynchronizable toggles or iCloud restores entries. Also updates CHANGELOG with fixes and refreshes example iOS Podfile.lock (SensitiveInfo -> 6.0.0 and related React binaries). --- CHANGELOG.md | 6 ++ .../com/sensitiveinfo/HybridSensitiveInfo.kt | 30 ++++++++ example/ios/Podfile.lock | 12 ++-- ios/HybridSensitiveInfo.swift | 70 +++++++++++++++++-- 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0adaa89..3bd8b807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ All additions are non-breaking; apps reading only the `biometry` boolean continue to work unchanged. +### Fixed + +* **ios:** `getItem` no longer triggers a second Face ID / Touch ID prompt for biometry-protected entries. The lazy re-encryption path that runs after a successful authenticated read used to call `SecItemUpdate` against the same Keychain item to refresh its key-version metadata; iOS treats that as a separate authorization gate, prompting the user a second time. Biometric items now skip the lazy refresh entirely and are upgraded only by an explicit `setItem` (full overwrite, single user-initiated write) or by `rotateKeys({ reEncryptEagerly: true })`. Non-biometric items continue to be upgraded silently. +* **android:** Same double-prompt regression on `getItem` for entries whose Keystore key was created with `setUserAuthenticationRequired(true)`. Lazy re-encryption inside `getItem` allocated a new key alias for the active version and `Cipher.init` on that fresh key required its own biometric authorization, surfacing as a second prompt right after the read. The lazy refresh now skips entries with `requiresAuthentication == true` (or any biometry-class access policy); explicit `setItem` and `rotateKeys({ reEncryptEagerly: true })` still upgrade them. +* **ios:** `setItem` no longer returns `errSecDuplicateItem` ("The specified item already exists in the keychain") when the caller toggles `iosSynchronizable` between writes or when iCloud Keychain restores an entry between our delete and add. The internal upsert helper now wipes prior entries with `kSecAttrSynchronizableAny` and absorbs the iCloud-restore race with a single bounded retry. Bundle ID + access group already scope the partition, so the overwrite never crosses an app or sharing boundary. + ## [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. diff --git a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt index 924e21c4..9248bb11 100644 --- a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt +++ b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt @@ -399,11 +399,41 @@ 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. the access policy maps to a biometric + * or device-credential gate). `devicePasscode`/`none` writes can be + * re-encrypted silently with no prompt. + */ + 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/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/ios/HybridSensitiveInfo.swift b/ios/HybridSensitiveInfo.swift index 3e6a8a36..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?, From b1e1623094268427318e1cee7adb5c630ff65014 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Apr 2026 17:14:46 -0300 Subject: [PATCH 4/6] Clarify access-control behavior, docs, and tests Clarify canUseAccessControl semantics in CHANGELOG and README (sync variant requires a snapshot; async will fetch one if none supplied) and improve wording around biometry/secure-enclave semantics. Update Android Kotlin docs in HybridSensitiveInfo to explain requiresBiometricAuth behavior, lazy refresh skipping, and legacy-entry handling. Make useBiometryStatusWatcher test deterministic by advancing Date.now via a jest spy instead of sleeping. Tweak SecurityAvailability.secureEnclave doc to describe cross-platform meaning and relation to StrongBox. --- CHANGELOG.md | 2 +- README.md | 2 +- .../com/sensitiveinfo/HybridSensitiveInfo.kt | 12 ++++++--- .../hooks.useBiometryStatusWatcher.test.tsx | 25 +++++++++++++------ src/sensitive-info.nitro.ts | 7 +++++- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd8b807..34318224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### Added * **biometric availability:** New `biometryStatus` field on `SecurityAvailability` (`'available' | 'notEnrolled' | 'notAvailable' | 'lockedOut' | 'unknown'`) disambiguates *hardware missing*, *hardware present but no enrollment*, and *currently usable*. The legacy `biometry` boolean stays as a backward-compatible alias for `biometryStatus === 'available'`. Mapped natively from `LAError` codes on iOS and `BiometricManager.canAuthenticate` results on Android. -* **policy precheck:** New `canUseAccessControl(policy, levels?)` and `canUseAccessControlSync(policy, levels)` predict whether a given `AccessControl` policy will succeed on the current device. Pure TS mapping over `SecurityAvailability` — no extra IPC round-trip. +* **policy precheck:** New `canUseAccessControl(policy, levels?)` and `canUseAccessControlSync(policy, levels)` predict whether a given `AccessControl` policy will succeed on the current device. When a `SecurityAvailability` snapshot is supplied (the sync variant always requires one), they are a pure TS mapping with no native call; if `levels` is omitted from `canUseAccessControl`, it first fetches the current snapshot via `getSupportedSecurityLevels()`. * **foreground auto-refresh:** `useSecurityAvailability({ refreshOnForeground: true })` subscribes to `AppState` and refetches on `active` transitions (debounced ~500 ms, unsubscribes on unmount). Covers the *user leaves to enroll a fingerprint and returns* flow without manual `refetch()`. * **enrollment listener:** New `useBiometryStatusWatcher(onChange)` hook fires only on actual `BiometryStatus` transitions (not on every render or refetch). Lives in its own module for tree-shaking. diff --git a/README.md b/README.md index 6d141548..2774101a 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,7 @@ The library disambiguates **capability** from **enrollment** so you can render t ### 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 the {@link SecurityAvailability} snapshot, no extra native round-trip: +`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' diff --git a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt index 9248bb11..a89b978e 100644 --- a/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt +++ b/android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt @@ -417,9 +417,15 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() { /** * True when the persisted entry's Keystore key requires user authentication - * to authorize a `Cipher.init` (i.e. the access policy maps to a biometric - * or device-credential gate). `devicePasscode`/`none` writes can be - * re-encrypted silently with no prompt. + * 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 diff --git a/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx b/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx index 775c01fd..2e810b9c 100644 --- a/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx +++ b/src/__tests__/hooks.useBiometryStatusWatcher.test.tsx @@ -64,14 +64,23 @@ describe('useBiometryStatusWatcher', () => { expect(onChange).toHaveBeenCalledTimes(1) // Foreground refresh that flips status: should fire with previous=notEnrolled. - await act(async () => { - // Wait past the 500 ms debounce window before emitting again. - await new Promise((r) => setTimeout(r, 600)) - appState.__emit('background') - appState.__emit('active') - }) + // 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') + await waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)) + expect(onChange).toHaveBeenLastCalledWith('available', 'notEnrolled') + } finally { + nowSpy.mockRestore() + } }) }) diff --git a/src/sensitive-info.nitro.ts b/src/sensitive-info.nitro.ts index 45bc1ce1..728e6af6 100644 --- a/src/sensitive-info.nitro.ts +++ b/src/sensitive-info.nitro.ts @@ -293,7 +293,12 @@ export type BiometryStatus = * `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 From bfceb1b70a757c574696090d7a05c11b707738ed Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Apr 2026 17:16:10 -0300 Subject: [PATCH 5/6] Update CHANGELOG for v6.1.0 Publish the v6.1.0 changelog entry (2026-04-28) and add documentation clarifications: explain SecurityAvailability.secureEnclave cross-platform semantics (Secure Enclave on iOS / mirrors strongBox on Android), clarify canUseAccessControl(snapshot vs fetch behavior), and update the Android requiresBiometricAuth doc comment to match actual classification and lazy-refresh behavior. --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34318224..361b424e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## [6.1.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0...v6.1.0) (2026-04-28) ### Added @@ -15,6 +15,12 @@ All additions are non-breaking; apps reading only the `biometry` boolean continu * **android:** Same double-prompt regression on `getItem` for entries whose Keystore key was created with `setUserAuthenticationRequired(true)`. Lazy re-encryption inside `getItem` allocated a new key alias for the active version and `Cipher.init` on that fresh key required its own biometric authorization, surfacing as a second prompt right after the read. The lazy refresh now skips entries with `requiresAuthentication == true` (or any biometry-class access policy); explicit `setItem` and `rotateKeys({ reEncryptEagerly: true })` still upgrade them. * **ios:** `setItem` no longer returns `errSecDuplicateItem` ("The specified item already exists in the keychain") when the caller toggles `iosSynchronizable` between writes or when iCloud Keychain restores an entry between our delete and add. The internal upsert helper now wipes prior entries with `kSecAttrSynchronizableAny` and absorbs the iCloud-restore race with a single bounded retry. Bundle ID + access group already scope the partition, so the overwrite never crosses an app or sharing boundary. +### Docs + +* Clarify `SecurityAvailability.secureEnclave` semantics: it now documents the cross-platform behaviour (Secure Enclave on iOS / mirrors `strongBox` on Android) so consumers can gate "hardware-backed key" UX without branching on `Platform.OS`. +* Clarify that `canUseAccessControl(policy, levels?)` only skips the native call when a snapshot is supplied; if `levels` is omitted it fetches one via `getSupportedSecurityLevels()`. +* Update the Android `requiresBiometricAuth` doc comment so it matches the actual classification (`devicePasscode` entries are auth-gated via `entry.requiresAuthentication` and are skipped by the lazy refresh). + ## [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. From 6f6402fe557366c3926a3f6155a7df5a6af4883f Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Apr 2026 17:18:46 -0300 Subject: [PATCH 6/6] chore(release): v6.1.0 --- CHANGELOG.md | 60 +++++----------------------------------------------- package.json | 2 +- 2 files changed, 6 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 361b424e..a55f8aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,63 +1,13 @@ -## [6.1.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0...v6.1.0) (2026-04-28) - -### Added - -* **biometric availability:** New `biometryStatus` field on `SecurityAvailability` (`'available' | 'notEnrolled' | 'notAvailable' | 'lockedOut' | 'unknown'`) disambiguates *hardware missing*, *hardware present but no enrollment*, and *currently usable*. The legacy `biometry` boolean stays as a backward-compatible alias for `biometryStatus === 'available'`. Mapped natively from `LAError` codes on iOS and `BiometricManager.canAuthenticate` results on Android. -* **policy precheck:** New `canUseAccessControl(policy, levels?)` and `canUseAccessControlSync(policy, levels)` predict whether a given `AccessControl` policy will succeed on the current device. When a `SecurityAvailability` snapshot is supplied (the sync variant always requires one), they are a pure TS mapping with no native call; if `levels` is omitted from `canUseAccessControl`, it first fetches the current snapshot via `getSupportedSecurityLevels()`. -* **foreground auto-refresh:** `useSecurityAvailability({ refreshOnForeground: true })` subscribes to `AppState` and refetches on `active` transitions (debounced ~500 ms, unsubscribes on unmount). Covers the *user leaves to enroll a fingerprint and returns* flow without manual `refetch()`. -* **enrollment listener:** New `useBiometryStatusWatcher(onChange)` hook fires only on actual `BiometryStatus` transitions (not on every render or refetch). Lives in its own module for tree-shaking. - -All additions are non-breaking; apps reading only the `biometry` boolean continue to work unchanged. - -### Fixed - -* **ios:** `getItem` no longer triggers a second Face ID / Touch ID prompt for biometry-protected entries. The lazy re-encryption path that runs after a successful authenticated read used to call `SecItemUpdate` against the same Keychain item to refresh its key-version metadata; iOS treats that as a separate authorization gate, prompting the user a second time. Biometric items now skip the lazy refresh entirely and are upgraded only by an explicit `setItem` (full overwrite, single user-initiated write) or by `rotateKeys({ reEncryptEagerly: true })`. Non-biometric items continue to be upgraded silently. -* **android:** Same double-prompt regression on `getItem` for entries whose Keystore key was created with `setUserAuthenticationRequired(true)`. Lazy re-encryption inside `getItem` allocated a new key alias for the active version and `Cipher.init` on that fresh key required its own biometric authorization, surfacing as a second prompt right after the read. The lazy refresh now skips entries with `requiresAuthentication == true` (or any biometry-class access policy); explicit `setItem` and `rotateKeys({ reEncryptEagerly: true })` still upgrade them. -* **ios:** `setItem` no longer returns `errSecDuplicateItem` ("The specified item already exists in the keychain") when the caller toggles `iosSynchronizable` between writes or when iCloud Keychain restores an entry between our delete and add. The internal upsert helper now wipes prior entries with `kSecAttrSynchronizableAny` and absorbs the iCloud-restore race with a single bounded retry. Bundle ID + access group already scope the partition, so the overwrite never crosses an app or sharing boundary. - -### Docs - -* Clarify `SecurityAvailability.secureEnclave` semantics: it now documents the cross-platform behaviour (Secure Enclave on iOS / mirrors `strongBox` on Android) so consumers can gate "hardware-backed key" UX without branching on `Platform.OS`. -* Clarify that `canUseAccessControl(policy, levels?)` only skips the native call when a snapshot is supplied; if `levels` is omitted it fetches one via `getSupportedSecurityLevels()`. -* Update the Android `requiresBiometricAuth` doc comment so it matches the actual classification (`devicePasscode` entries are auth-gated via `entry.requiresAuthentication` and are skipped by the lazy refresh). - -## [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/package.json b/package.json index dc6d7015..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",