Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 5 additions & 32 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,13 @@
## [6.0.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.12...v6.0.0) (2026-04-28)

First stable release of the Nitro-based v6 line. Promotes `6.0.0-rc.12` to GA with no API changes — the release notes below summarize everything new since the v5 line.
## [6.1.0](https://github.com/mcodex/react-native-sensitive-info/compare/v6.0.0-rc.12...v6.1.0) (2026-04-28)

### Features

* **rotation:** Add versioned key rotation via `rotateKeys()` and `getKeyVersion()` with lazy re-encryption on read. New `useKeyRotation` hook exposes the same flow declaratively.
* **security hardening:** Defense-in-depth pass — non-breaking, applied transparently to new writes and via lazy upgrade on rotation:
- HMAC-SHA256 integrity tag bound to every entry's metadata + ciphertext, surfaced on `StorageMetadata.integrityTag`. Tampering with SharedPreferences/Keychain attributes now raises `IntegrityViolationError` (`E_INTEGRITY_VIOLATION`) before any biometric prompt is shown.
- AES-GCM AAD on Android binds ciphertext to `service|key|v<version>`, 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_<hash>_v<version>`) 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
Expand Down
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -430,11 +431,79 @@ See `src/sensitive-info.nitro.ts` for full TypeScript definitions.
- **Access policies** — `secureEnclaveBiometry`, `biometryCurrentSet`, `biometryAny`, `devicePasscode`, `none`.
- **Timestamp** — UNIX seconds when the entry was last written.

Use `getSupportedSecurityLevels()` to tailor UX before prompting users. For example, disable Secure Enclave options on simulators.
Use `getSupportedSecurityLevels()` to tailor UX before prompting users. For example, disable Secure Enclave options on simulators. For richer enrollment-state detection (so you can distinguish *"hardware missing"* from *"user hasn't enrolled yet"*), see [👁️ Biometrics](#️-biometrics).

> [!TIP]
> Need to demo biometrics on a simulator? Use Xcode’s “Features → Face ID” and Android Studio’s “Fingerprints” toggles to simulate successful scans.

## 👁️ Biometrics

The library disambiguates **capability** from **enrollment** so you can render the right UX without false positives. `SecurityAvailability` exposes both a quick boolean (`biometry`) and a fine-grained `biometryStatus` enum:

| `biometryStatus` | Meaning | Recommended UX |
| --- | --- | --- |
| `'available'` | Hardware present, enrolled, currently usable. | Enable the biometric toggle. |
| `'notEnrolled'` | Hardware present but no fingerprint/face is registered. | Show a *“Set up Face ID / fingerprint”* CTA that deep-links to settings. |
| `'notAvailable'` | Missing or permanently disabled (no hardware, admin policy, passcode unset). | Hide the biometric toggle entirely. |
| `'lockedOut'` | Too many failed attempts; transiently locked. iOS only at probe time — Android surfaces lockout via `BiometricPrompt` failures. | Show *“Try again later”* and offer a `devicePasscode` fallback. |
| `'unknown'` | Probe could not classify the device. | Treat as `notAvailable` for gating; log for diagnostics. |

> Invariant: `biometry === (biometryStatus === 'available')`. Both fields come from the same native probe.

### Gate a toggle on a specific access-control policy

`canUseAccessControl(policy)` predicts whether a future `setItem` write with the requested policy will succeed on the current device. It maps the policy onto a [`SecurityAvailability`](#-access-control--metadata) snapshot — pure TS, no native call — but if you don't pass a snapshot it first fetches one via `getSupportedSecurityLevels()`. Pass the snapshot you already hold (e.g. from `useSecurityAvailability`) to skip that round-trip:

```ts
import { canUseAccessControl, setItem } from 'react-native-sensitive-info'

if (await canUseAccessControl('secureEnclaveBiometry')) {
await setItem('session', token, { accessControl: 'secureEnclaveBiometry' })
} else {
// Graceful fallback so the user can still sign in.
await setItem('session', token, { accessControl: 'devicePasscode' })
}
```

If you already hold a snapshot from `useSecurityAvailability`, use the synchronous variant inside render:

```tsx
import { canUseAccessControlSync } from 'react-native-sensitive-info'
import { useSecurityAvailability } from 'react-native-sensitive-info/hooks'

const { data: caps } = useSecurityAvailability()
const canEnable = caps ? canUseAccessControlSync('secureEnclaveBiometry', caps) : false
```

### Auto-refresh when the user returns from system settings

Users commonly leave the app to enroll a fingerprint and come back. Opt into foreground auto-refresh so the toggle reflects the new state without a manual `refetch()`:

```tsx
const { data: caps } = useSecurityAvailability({ refreshOnForeground: true })

if (caps?.biometryStatus === 'notEnrolled') {
return <SetupFaceIdCta onPress={() => 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.
Expand Down
37 changes: 37 additions & 0 deletions android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
secureEnclave = capabilities.secureEnclave,
strongBox = capabilities.strongBox,
biometry = capabilities.biometry,
biometryStatus = capabilities.biometryStatus,
deviceCredential = capabilities.deviceCredential
)
}
Expand Down Expand Up @@ -398,11 +399,47 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
val activeVersion = deps.keyVersionRegistry.get(service)
if (entry.keyVersion >= activeVersion) return entry

// Skip lazy re-encryption for biometry-protected entries. Re-encryption
// creates a new Keystore key alias for `activeVersion` and `Cipher.init` on
// a `setUserAuthenticationRequired(true)` key requires its own biometric
// authorization — surfacing as a *second* Face/fingerprint prompt right
// after the user already authenticated for the read.
//
// These items are still upgraded by:
// - the next explicit `setItem` (caller-initiated full overwrite), or
// - `rotateKeys({ reEncryptEagerly: true })` where the prompt is expected.
if (requiresBiometricAuth(entry)) return entry

return runCatching {
reEncryptEntry(deps, service, key, entry, plaintext, activeVersion, prompt)
}.getOrDefault(entry)
}

/**
* True when the persisted entry's Keystore key requires user authentication
* to authorize a `Cipher.init` — i.e. biometric- or device-credential-gated
* entries. `entry.requiresAuthentication` already covers the common case
* (including `devicePasscode`, which `AccessControlResolver` flags as
* auth-required), so any such entry returns `true` and is skipped by the
* lazy refresh to avoid a second prompt. The `accessControl` fallback only
* matters for legacy entries persisted before the flag existed: there we
* still classify the biometry-class policies as auth-gated, while
* `devicePasscode`/`none` legacy entries are treated as silently
* upgradable (their keys had no auth requirement back then).
*/
private fun requiresBiometricAuth(entry: PersistedEntry): Boolean {
if (entry.requiresAuthentication) return true
val accessControl = entry.metadata.toStorageMetadata()?.accessControl
?: return false
return when (accessControl) {
AccessControl.SECUREENCLAVEBIOMETRY,
AccessControl.BIOMETRYCURRENTSET,
AccessControl.BIOMETRYANY -> true
AccessControl.DEVICEPASSCODE,
AccessControl.NONE -> false
}
}

private suspend fun reEncryptEntry(
deps: Dependencies,
service: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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

internal data class SecurityAvailabilitySnapshot(
val secureEnclave: Boolean,
val strongBox: Boolean,
val biometry: Boolean,
val biometryStatus: BiometryStatus,
val strongBiometrics: Boolean,
val deviceCredential: Boolean
)
Expand All @@ -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<KeyguardManager>()
val deviceCredential = keyguard?.isDeviceSecure == true

Expand All @@ -50,11 +58,40 @@ internal class SecurityAvailabilityResolver(private val context: Context) {
secureEnclave = hasStrongBox,
strongBox = hasStrongBox,
biometry = hasBiometry,
biometryStatus = biometryStatus,
strongBiometrics = hasStrongBiometrics,
deviceCredential = deviceCredential
)
cached = snapshot
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
}
}
38 changes: 35 additions & 3 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading
Loading