Skip to content

Commit 4a1d00f

Browse files
authored
Merge pull request #592 from mCodex/feat/biometryStatus
Feat/biometry status
2 parents d283060 + 6f6402f commit 4a1d00f

31 files changed

Lines changed: 1295 additions & 80 deletions

CHANGELOG.md

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,13 @@
1-
## [6.0.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.12...v6.0.0) (2026-04-28)
2-
3-
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.
1+
## [6.1.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.12...v6.1.0) (2026-04-28)
42

53
### Features
64

7-
* **rotation:** Add versioned key rotation via `rotateKeys()` and `getKeyVersion()` with lazy re-encryption on read. New `useKeyRotation` hook exposes the same flow declaratively.
8-
* **security hardening:** Defense-in-depth pass — non-breaking, applied transparently to new writes and via lazy upgrade on rotation:
9-
- 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.
10-
- AES-GCM AAD on Android binds ciphertext to `service|key|v<version>`, defeating cross-entry swap attacks.
11-
- `setUnlockedDeviceRequired(true)` on every Android Keystore key (API 28+), mirroring iOS's `kSecAttrAccessibleWhenUnlocked` semantics.
12-
- Plaintext byte buffers are zeroized after use on both platforms.
13-
- Constant-time HMAC comparison via `MessageDigest.isEqual` / manual `UInt8` XOR fold.
14-
- Backwards compatible: entries written by earlier versions decode without verification and are upgraded on the next write or rotation.
15-
* **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.
16-
* **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.
17-
* **nitro 0.35:** Regenerated against `nitrogen@0.35.5` and `react-native-nitro-modules@0.35.5`.
18-
* **tooling:** Migrated linting/formatting from ESLint + Prettier to **Biome 2**. Single config at `biome.json`, faster CI runs.
19-
20-
### Refactor (KISS · DRY · SRP)
21-
22-
* 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.
23-
* `useSecureStorage` shrunk from ~230 LOC to ~180 LOC and reuses the shared abort + auth-cancel semantics; behaviour is unchanged.
24-
* Test fixtures consolidated in `src/__tests__/__mocks__/fixtures.ts` (`buildTestItem`, `buildTestMetadata`).
25-
* Removed redundant re-exports from `src/internal/errors.ts`.
26-
27-
### Breaking changes
5+
* add AccessControlCard and DiagnosticsCard components; remove unused components ([6701d84](https://github.com/mcodex/react-native-sensitive-info/commit/6701d84226b97cf587064c596f871e8395aa7250))
6+
* implement integrity hardening for sensitive data storage ([60b2cf5](https://github.com/mcodex/react-native-sensitive-info/commit/60b2cf5c8520cb40f917a698f326a239861e5d10))
287

29-
* The default export is gone. Use named imports: `import { setItem } from 'react-native-sensitive-info'`.
30-
* React hooks are no longer re-exported from the package root — import them from `react-native-sensitive-info/hooks`.
31-
32-
### Notes
33-
34-
* **iOS rotation** updates the Keychain metadata via `SecItemUpdate`, preserving the original access-control attributes while bumping `keyVersion`.
35-
* **Android rotation** mints a fresh per-entry Keystore alias (`SensitiveInfo_<hash>_v<version>`) during lazy or eager re-encryption and deletes the stale alias after a successful rewrite.
36-
* Version state lives in a non-secret registry (`SharedPreferences` on Android, `UserDefaults` on iOS). Delete the app's data to reset.
8+
### Bug Fixes
379

10+
* update module resolver alias to correctly map source directory for subpath imports ([106621e](https://github.com/mcodex/react-native-sensitive-info/commit/106621e8d4c0cba81987196054a971628b4a36d8))
3811
## [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)
3912

4013
### Features

README.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Modern secure storage for React Native, powered by Nitro Modules. Version 6 ship
2828
- [⚡️ Quick start](#-quick-start)
2929
- [📚 API reference](#-api-reference)
3030
- [🔐 Access control & metadata](#-access-control--metadata)
31+
- [👁️ Biometrics](#️-biometrics)
3132
- [❗ Error handling](#-error-handling)
3233
- [🔁 Key rotation](#-key-rotation)
3334
- [🛡️ Security model](#-security-model)
@@ -430,11 +431,79 @@ See `src/sensitive-info.nitro.ts` for full TypeScript definitions.
430431
- **Access policies**`secureEnclaveBiometry`, `biometryCurrentSet`, `biometryAny`, `devicePasscode`, `none`.
431432
- **Timestamp** — UNIX seconds when the entry was last written.
432433

433-
Use `getSupportedSecurityLevels()` to tailor UX before prompting users. For example, disable Secure Enclave options on simulators.
434+
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).
434435

435436
> [!TIP]
436437
> Need to demo biometrics on a simulator? Use Xcode’s “Features → Face ID” and Android Studio’s “Fingerprints” toggles to simulate successful scans.
437438
439+
## 👁️ Biometrics
440+
441+
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:
442+
443+
| `biometryStatus` | Meaning | Recommended UX |
444+
| --- | --- | --- |
445+
| `'available'` | Hardware present, enrolled, currently usable. | Enable the biometric toggle. |
446+
| `'notEnrolled'` | Hardware present but no fingerprint/face is registered. | Show a *“Set up Face ID / fingerprint”* CTA that deep-links to settings. |
447+
| `'notAvailable'` | Missing or permanently disabled (no hardware, admin policy, passcode unset). | Hide the biometric toggle entirely. |
448+
| `'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. |
449+
| `'unknown'` | Probe could not classify the device. | Treat as `notAvailable` for gating; log for diagnostics. |
450+
451+
> Invariant: `biometry === (biometryStatus === 'available')`. Both fields come from the same native probe.
452+
453+
### Gate a toggle on a specific access-control policy
454+
455+
`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:
456+
457+
```ts
458+
import { canUseAccessControl, setItem } from 'react-native-sensitive-info'
459+
460+
if (await canUseAccessControl('secureEnclaveBiometry')) {
461+
await setItem('session', token, { accessControl: 'secureEnclaveBiometry' })
462+
} else {
463+
// Graceful fallback so the user can still sign in.
464+
await setItem('session', token, { accessControl: 'devicePasscode' })
465+
}
466+
```
467+
468+
If you already hold a snapshot from `useSecurityAvailability`, use the synchronous variant inside render:
469+
470+
```tsx
471+
import { canUseAccessControlSync } from 'react-native-sensitive-info'
472+
import { useSecurityAvailability } from 'react-native-sensitive-info/hooks'
473+
474+
const { data: caps } = useSecurityAvailability()
475+
const canEnable = caps ? canUseAccessControlSync('secureEnclaveBiometry', caps) : false
476+
```
477+
478+
### Auto-refresh when the user returns from system settings
479+
480+
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()`:
481+
482+
```tsx
483+
const { data: caps } = useSecurityAvailability({ refreshOnForeground: true })
484+
485+
if (caps?.biometryStatus === 'notEnrolled') {
486+
return <SetupFaceIdCta onPress={() => Linking.openSettings()} />
487+
}
488+
```
489+
490+
The hook subscribes to `AppState` only when the option is enabled, debounces back-to-back `active` transitions (~500 ms), and unsubscribes on unmount.
491+
492+
### React to enrollment changes
493+
494+
`useBiometryStatusWatcher` is a transition-only callback (fires once per actual `BiometryStatus` change, never on every render):
495+
496+
```tsx
497+
import { useBiometryStatusWatcher } from 'react-native-sensitive-info/hooks'
498+
499+
useBiometryStatusWatcher((next, previous) => {
500+
analytics.track('biometry_status_changed', { from: previous, to: next })
501+
if (previous === 'notEnrolled' && next === 'available') showToast('Face ID is ready.')
502+
})
503+
```
504+
505+
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).
506+
438507
## 🧪 Simulators and emulators
439508

440509
- iOS simulators do not offer Secure Enclave hardware. Biometric prompts usually fall back to a passcode dialog.

android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
218218
secureEnclave = capabilities.secureEnclave,
219219
strongBox = capabilities.strongBox,
220220
biometry = capabilities.biometry,
221+
biometryStatus = capabilities.biometryStatus,
221222
deviceCredential = capabilities.deviceCredential
222223
)
223224
}
@@ -398,11 +399,47 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
398399
val activeVersion = deps.keyVersionRegistry.get(service)
399400
if (entry.keyVersion >= activeVersion) return entry
400401

402+
// Skip lazy re-encryption for biometry-protected entries. Re-encryption
403+
// creates a new Keystore key alias for `activeVersion` and `Cipher.init` on
404+
// a `setUserAuthenticationRequired(true)` key requires its own biometric
405+
// authorization — surfacing as a *second* Face/fingerprint prompt right
406+
// after the user already authenticated for the read.
407+
//
408+
// These items are still upgraded by:
409+
// - the next explicit `setItem` (caller-initiated full overwrite), or
410+
// - `rotateKeys({ reEncryptEagerly: true })` where the prompt is expected.
411+
if (requiresBiometricAuth(entry)) return entry
412+
401413
return runCatching {
402414
reEncryptEntry(deps, service, key, entry, plaintext, activeVersion, prompt)
403415
}.getOrDefault(entry)
404416
}
405417

418+
/**
419+
* True when the persisted entry's Keystore key requires user authentication
420+
* to authorize a `Cipher.init` — i.e. biometric- or device-credential-gated
421+
* entries. `entry.requiresAuthentication` already covers the common case
422+
* (including `devicePasscode`, which `AccessControlResolver` flags as
423+
* auth-required), so any such entry returns `true` and is skipped by the
424+
* lazy refresh to avoid a second prompt. The `accessControl` fallback only
425+
* matters for legacy entries persisted before the flag existed: there we
426+
* still classify the biometry-class policies as auth-gated, while
427+
* `devicePasscode`/`none` legacy entries are treated as silently
428+
* upgradable (their keys had no auth requirement back then).
429+
*/
430+
private fun requiresBiometricAuth(entry: PersistedEntry): Boolean {
431+
if (entry.requiresAuthentication) return true
432+
val accessControl = entry.metadata.toStorageMetadata()?.accessControl
433+
?: return false
434+
return when (accessControl) {
435+
AccessControl.SECUREENCLAVEBIOMETRY,
436+
AccessControl.BIOMETRYCURRENTSET,
437+
AccessControl.BIOMETRYANY -> true
438+
AccessControl.DEVICEPASSCODE,
439+
AccessControl.NONE -> false
440+
}
441+
}
442+
406443
private suspend fun reEncryptEntry(
407444
deps: Dependencies,
408445
service: String,

android/src/main/java/com/sensitiveinfo/internal/crypto/SecurityAvailabilityResolver.kt

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import android.os.Build
66
import androidx.biometric.BiometricManager
77
import androidx.biometric.BiometricManager.Authenticators
88
import androidx.core.content.getSystemService
9+
import com.margelo.nitro.sensitiveinfo.BiometryStatus
910
import java.util.concurrent.locks.ReentrantLock
1011
import kotlin.concurrent.withLock
1112

1213
internal data class SecurityAvailabilitySnapshot(
1314
val secureEnclave: Boolean,
1415
val strongBox: Boolean,
1516
val biometry: Boolean,
17+
val biometryStatus: BiometryStatus,
1618
val strongBiometrics: Boolean,
1719
val deviceCredential: Boolean
1820
)
@@ -36,10 +38,16 @@ internal class SecurityAvailabilityResolver(private val context: Context) {
3638
}
3739

3840
val biometricManager = BiometricManager.from(context)
39-
val hasStrongBiometrics = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
40-
val hasWeakBiometrics = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
41+
val strongResult = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_STRONG)
42+
val weakResult = biometricManager.canAuthenticate(Authenticators.BIOMETRIC_WEAK)
43+
val hasStrongBiometrics = strongResult == BiometricManager.BIOMETRIC_SUCCESS
44+
val hasWeakBiometrics = weakResult == BiometricManager.BIOMETRIC_SUCCESS
4145
val hasBiometry = hasStrongBiometrics || hasWeakBiometrics
4246

47+
// Combine the strong/weak probe results so that the most informative reason wins.
48+
// Order of precedence: SUCCESS > NONE_ENROLLED > NO_HARDWARE/HW_UNAVAILABLE/SECURITY_UPDATE_REQUIRED > UNKNOWN/UNSUPPORTED.
49+
val biometryStatus = classifyBiometryStatus(strongResult, weakResult)
50+
4351
val keyguard = context.getSystemService<KeyguardManager>()
4452
val deviceCredential = keyguard?.isDeviceSecure == true
4553

@@ -50,11 +58,40 @@ internal class SecurityAvailabilityResolver(private val context: Context) {
5058
secureEnclave = hasStrongBox,
5159
strongBox = hasStrongBox,
5260
biometry = hasBiometry,
61+
biometryStatus = biometryStatus,
5362
strongBiometrics = hasStrongBiometrics,
5463
deviceCredential = deviceCredential
5564
)
5665
cached = snapshot
5766
return snapshot
5867
}
5968
}
69+
70+
private fun classifyBiometryStatus(strongResult: Int, weakResult: Int): BiometryStatus {
71+
// SUCCESS on either tier means we can authenticate now.
72+
if (strongResult == BiometricManager.BIOMETRIC_SUCCESS ||
73+
weakResult == BiometricManager.BIOMETRIC_SUCCESS
74+
) {
75+
return BiometryStatus.AVAILABLE
76+
}
77+
78+
// Hardware exists but no fingerprint/face is enrolled.
79+
if (strongResult == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ||
80+
weakResult == BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
81+
) {
82+
return BiometryStatus.NOTENROLLED
83+
}
84+
85+
// Permanently or contextually unavailable.
86+
val unavailableCodes = setOf(
87+
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
88+
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE,
89+
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED
90+
)
91+
if (strongResult in unavailableCodes || weakResult in unavailableCodes) {
92+
return BiometryStatus.NOTAVAILABLE
93+
}
94+
95+
return BiometryStatus.UNKNOWN
96+
}
6097
}

babel.config.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,36 @@
1-
module.exports = {
2-
plugins: ['babel-plugin-react-compiler'],
3-
presets: ['module:@react-native/babel-preset'],
1+
/**
2+
* The library's babel config has a single job: ensure
3+
* `babel-plugin-react-compiler` runs on every code path that produces
4+
* shipped JavaScript.
5+
*
6+
* - When `react-native-builder-bob` invokes us (bob targets set
7+
* `configFile: true` so they pick up this file), we extend bob's own
8+
* preset — it already takes care of `@babel/preset-env`, JSX, TS, and
9+
* import-extension rewriting. Adding our own RN preset would mis-target
10+
* the published bundle for Hermes only.
11+
* - For any other caller (jest is ts-jest only and ignores this file, but
12+
* IDE tooling and `metro` in the example app may load it), fall back to
13+
* the standard React Native preset.
14+
*
15+
* The compiler plugin is listed BEFORE other plugins so it operates on
16+
* pristine source.
17+
*/
18+
module.exports = (api) => {
19+
const isBob = api.caller((caller) =>
20+
caller != null ? caller.name === 'react-native-builder-bob' : false
21+
)
22+
23+
const plugins = ['babel-plugin-react-compiler']
24+
25+
if (isBob) {
26+
return {
27+
presets: [require.resolve('react-native-builder-bob/babel-preset')],
28+
plugins,
29+
}
30+
}
31+
32+
return {
33+
presets: ['module:@react-native/babel-preset'],
34+
plugins,
35+
}
436
}

0 commit comments

Comments
 (0)