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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](https://github.com/mcodex/react-native-sensitive-info)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

Hardware-backed secure storage for React Native. Secrets are encrypted with AES-GCM, gated by biometrics or device credentials, and stored in the system Keychain (iOS/Apple) or Android Keystore — all behind a simple Promise-based API and first-class React hooks.
Hardware-backed secure storage for React Native. Secrets are encrypted with AES-GCM, gated by biometrics or device credentials, and stored in the system Keychain (iOS) or Android Keystore behind a Promise-based API and React hooks.

> [!NOTE]
> **Upgrading from v5?** See [MIGRATION.md](./docs/MIGRATION.md). v6 requires the New Architecture (RN 0.76+) and `react-native-nitro-modules`. Windows support was removed.
Expand Down Expand Up @@ -98,7 +98,7 @@ console.log(item?.metadata.securityLevel) // e.g. 'secureEnclave'
await deleteItem('session-token', { service: 'auth' })
```

Building a component? The [hooks API](#️-react-hooks-api-recommended) handles loading states, cleanup, and error boundaries — no `useEffect` boilerplate needed.
Building a component? The [hooks API](#️-react-hooks-api-recommended) handles loading states, cleanup, and error boundaries.

## ⚛️ React Hooks API (Recommended)

Expand Down Expand Up @@ -177,7 +177,7 @@ import {

## 🛡️ Security

Every secret is encrypted with **AES-GCM** and bound to a hardware-protected key — the **Secure Enclave** on Apple platforms and the **Android Keystore** (StrongBox when available). Each entry carries an **HMAC-SHA256 integrity tag** recomputed on every read; a mismatch raises `IntegrityViolationError` before any biometric prompt fires, so spoofed entries can never trigger user authentication.
Every secret is encrypted with **AES-GCM** and bound to a hardware-protected key. Each entry carries an **HMAC-SHA256 integrity tag** recomputed on every read; a mismatch raises `IntegrityViolationError` before any biometric prompt fires.

For the full cryptographic model — key derivation, AAD binding, replay defense, and threat classification — see [THREAT_MODEL.md](./docs/THREAT_MODEL.md).

Expand Down
15 changes: 4 additions & 11 deletions android/src/main/java/com/sensitiveinfo/HybridSensitiveInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
val accessControlResolver = AccessControlResolver(securityAvailabilityResolver)
val serviceNameResolver = ServiceNameResolver(ctx)
val authenticator = BiometricAuthenticator()
val cryptoManager = CryptoManager(authenticator)
val cryptoManager = CryptoManager(authenticator, ctx)

Dependencies(
context = ctx,
Expand Down Expand Up @@ -416,16 +416,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
}

/**
* True when the persisted entry's Keystore key requires user authentication
* to authorize a `Cipher.init` — i.e. biometric- or device-credential-gated
* entries. `entry.requiresAuthentication` already covers the common case
* (including `devicePasscode`, which `AccessControlResolver` flags as
* auth-required), so any such entry returns `true` and is skipped by the
* lazy refresh to avoid a second prompt. The `accessControl` fallback only
* matters for legacy entries persisted before the flag existed: there we
* still classify the biometry-class policies as auth-gated, while
* `devicePasscode`/`none` legacy entries are treated as silently
* upgradable (their keys had no auth requirement back then).
* True when the entry's Keystore key requires user authentication to authorize
* a `Cipher.init`. The `accessControl` fallback handles legacy entries
* persisted before `requiresAuthentication` existed.
*/
private fun requiresBiometricAuth(entry: PersistedEntry): Boolean {
if (entry.requiresAuthentication) return true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.sensitiveinfo.internal.crypto

import android.app.KeyguardManager
import android.content.Context
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
Expand Down Expand Up @@ -30,10 +32,16 @@ private const val TRANSFORMATION = "AES/GCM/NoPadding"
* from disk.
*/
internal class CryptoManager(
private val authenticator: BiometricAuthenticator
private val authenticator: BiometricAuthenticator,
private val context: Context
) {
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }

private fun hasSecureLockScreen(): Boolean {
val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
return keyguard?.isDeviceLocked == true
}

/** Encrypts data and returns the ciphertext plus generated IV. */
suspend fun encrypt(
alias: String,
Expand All @@ -49,27 +57,14 @@ internal class CryptoManager(

val readyCipher = try {
if (!requiresAuth) {
cipher.init(Cipher.ENCRYPT_MODE, key)
cipher
initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
} else if (supportsKeystoreAuth) {
try {
cipher.init(Cipher.ENCRYPT_MODE, key)
} catch (invalidated: KeyPermanentlyInvalidatedException) {
deleteKey(alias)
throw IllegalStateException("Encryption key invalidated. Item must be recreated.", invalidated)
}

initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
val authenticated = authenticator.authenticate(prompt, resolution.allowedAuthenticators, cipher)
(authenticated ?: cipher)
} else {
authenticator.authenticate(prompt, resolution.allowedAuthenticators, null)
try {
cipher.init(Cipher.ENCRYPT_MODE, key)
} catch (invalidated: KeyPermanentlyInvalidatedException) {
deleteKey(alias)
throw IllegalStateException("Encryption key invalidated. Item must be recreated.", invalidated)
}
cipher
initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
}
} catch (error: CancellationException) {
throw error
Expand Down Expand Up @@ -103,41 +98,14 @@ internal class CryptoManager(

val readyCipher = try {
if (!requiresAuth) {
try {
cipher.init(Cipher.DECRYPT_MODE, key, spec)
} catch (invalidated: KeyPermanentlyInvalidatedException) {
deleteKey(alias)
throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
} catch (unrecoverable: UnrecoverableKeyException) {
deleteKey(alias)
throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
}
cipher
initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
} else if (supportsKeystoreAuth) {
try {
cipher.init(Cipher.DECRYPT_MODE, key, spec)
} catch (invalidated: KeyPermanentlyInvalidatedException) {
deleteKey(alias)
throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
} catch (unrecoverable: UnrecoverableKeyException) {
deleteKey(alias)
throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
}

initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
val authenticated = authenticator.authenticate(prompt, resolution.allowedAuthenticators, cipher)
(authenticated ?: cipher)
} else {
authenticator.authenticate(prompt, resolution.allowedAuthenticators, null)
try {
cipher.init(Cipher.DECRYPT_MODE, key, spec)
} catch (invalidated: KeyPermanentlyInvalidatedException) {
deleteKey(alias)
throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
} catch (unrecoverable: UnrecoverableKeyException) {
deleteKey(alias)
throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
}
cipher
initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
}
} catch (error: CancellationException) {
throw error
Expand All @@ -158,11 +126,30 @@ internal class CryptoManager(
}
}

private fun initCipher(
cipher: Cipher,
mode: Int,
key: SecretKey,
spec: GCMParameterSpec?,
alias: String
): Cipher {
try {
if (spec != null) cipher.init(mode, key, spec) else cipher.init(mode, key)
} catch (invalidated: KeyPermanentlyInvalidatedException) {
deleteKey(alias)
val action = if (mode == Cipher.ENCRYPT_MODE) "Encryption" else "Decryption"
throw IllegalStateException("$action key invalidated. Item must be recreated.", invalidated)
} catch (unrecoverable: UnrecoverableKeyException) {
deleteKey(alias)
val action = if (mode == Cipher.ENCRYPT_MODE) "Encryption" else "Decryption"
throw IllegalStateException("$action key unavailable. Item must be recreated.", unrecoverable)
}
return cipher
}

/**
* Reconstructs the resolution for data loaded from SharedPreferences.
*
* This lets us decrypt entries that were encrypted on a previous run without re-reading
* the original access-control input, since the persisted metadata is authoritative.
* Reconstructs the resolution for data loaded from SharedPreferences,
* using the persisted metadata as the source of truth.
*/
fun buildResolutionForPersisted(
accessControl: AccessControl,
Expand Down Expand Up @@ -213,7 +200,9 @@ internal class CryptoManager(

// Defense in depth: require the device to be unlocked at the moment of use, mirroring iOS's
// `kSecAttrAccessibleWhenUnlocked` default. Available on API 28+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// Skip when no secure screen lock exists — setUnlockedDeviceRequired crashes on Android 12-14
// without one (https://issuetracker.google.com/issues/191391068, fixed in Android 15).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && hasSecureLockScreen()) {
try {
builder.setUnlockedDeviceRequired(true)
} catch (_: Throwable) {
Expand Down Expand Up @@ -249,7 +238,7 @@ internal class CryptoManager(
}

if (resolution.accessControl == AccessControl.DEVICEPASSCODE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && hasSecureLockScreen()) {
builder.setUnlockedDeviceRequired(true)
}
}
Expand Down
24 changes: 20 additions & 4 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
Expand Down Expand Up @@ -28,7 +28,7 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"preset": "recommended",
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error",
Expand All @@ -37,10 +37,18 @@
},
"style": {
"useImportType": "error",
"useConsistentArrayType": "error"
"useConsistentArrayType": "error",
"useErrorCause": "warn",
"useConsistentEnumValueType": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
"noExplicitAny": "warn",
"noShadow": "warn",
"noUnnecessaryConditions": "warn",
"noNestedPromises": "error"
},
"security": {
"noScriptUrl": "error"
}
}
},
Expand All @@ -64,6 +72,14 @@
"suspicious": { "noExplicitAny": "off" }
}
}
},
{
"includes": ["src/hooks/**"],
"linter": {
"rules": {
"suspicious": { "noUnnecessaryConditions": "off" }
}
}
}
]
}
Loading
Loading