Skip to content

Commit 72149f7

Browse files
authored
Merge pull request #658 from mCodex/chore-updating-libs
chore: cleaning code base
2 parents aee6d55 + 3eafecb commit 72149f7

19 files changed

Lines changed: 2833 additions & 4894 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](https://github.com/mcodex/react-native-sensitive-info)
66
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
77

8-
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.
8+
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.
99

1010
> [!NOTE]
1111
> **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.
@@ -98,7 +98,7 @@ console.log(item?.metadata.securityLevel) // e.g. 'secureEnclave'
9898
await deleteItem('session-token', { service: 'auth' })
9999
```
100100

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

103103
## ⚛️ React Hooks API (Recommended)
104104

@@ -177,7 +177,7 @@ import {
177177

178178
## 🛡️ Security
179179

180-
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.
180+
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.
181181

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

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

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
6363
val accessControlResolver = AccessControlResolver(securityAvailabilityResolver)
6464
val serviceNameResolver = ServiceNameResolver(ctx)
6565
val authenticator = BiometricAuthenticator()
66-
val cryptoManager = CryptoManager(authenticator)
66+
val cryptoManager = CryptoManager(authenticator, ctx)
6767

6868
Dependencies(
6969
context = ctx,
@@ -416,16 +416,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
416416
}
417417

418418
/**
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).
419+
* True when the entry's Keystore key requires user authentication to authorize
420+
* a `Cipher.init`. The `accessControl` fallback handles legacy entries
421+
* persisted before `requiresAuthentication` existed.
429422
*/
430423
private fun requiresBiometricAuth(entry: PersistedEntry): Boolean {
431424
if (entry.requiresAuthentication) return true

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

Lines changed: 42 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.sensitiveinfo.internal.crypto
22

3+
import android.app.KeyguardManager
4+
import android.content.Context
35
import android.os.Build
46
import android.security.keystore.KeyGenParameterSpec
57
import android.security.keystore.KeyPermanentlyInvalidatedException
@@ -30,10 +32,16 @@ private const val TRANSFORMATION = "AES/GCM/NoPadding"
3032
* from disk.
3133
*/
3234
internal class CryptoManager(
33-
private val authenticator: BiometricAuthenticator
35+
private val authenticator: BiometricAuthenticator,
36+
private val context: Context
3437
) {
3538
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
3639

40+
private fun hasSecureLockScreen(): Boolean {
41+
val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
42+
return keyguard?.isDeviceLocked == true
43+
}
44+
3745
/** Encrypts data and returns the ciphertext plus generated IV. */
3846
suspend fun encrypt(
3947
alias: String,
@@ -49,27 +57,14 @@ internal class CryptoManager(
4957

5058
val readyCipher = try {
5159
if (!requiresAuth) {
52-
cipher.init(Cipher.ENCRYPT_MODE, key)
53-
cipher
60+
initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
5461
} else if (supportsKeystoreAuth) {
55-
try {
56-
cipher.init(Cipher.ENCRYPT_MODE, key)
57-
} catch (invalidated: KeyPermanentlyInvalidatedException) {
58-
deleteKey(alias)
59-
throw IllegalStateException("Encryption key invalidated. Item must be recreated.", invalidated)
60-
}
61-
62+
initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
6263
val authenticated = authenticator.authenticate(prompt, resolution.allowedAuthenticators, cipher)
6364
(authenticated ?: cipher)
6465
} else {
6566
authenticator.authenticate(prompt, resolution.allowedAuthenticators, null)
66-
try {
67-
cipher.init(Cipher.ENCRYPT_MODE, key)
68-
} catch (invalidated: KeyPermanentlyInvalidatedException) {
69-
deleteKey(alias)
70-
throw IllegalStateException("Encryption key invalidated. Item must be recreated.", invalidated)
71-
}
72-
cipher
67+
initCipher(cipher, Cipher.ENCRYPT_MODE, key, null, alias)
7368
}
7469
} catch (error: CancellationException) {
7570
throw error
@@ -103,41 +98,14 @@ internal class CryptoManager(
10398

10499
val readyCipher = try {
105100
if (!requiresAuth) {
106-
try {
107-
cipher.init(Cipher.DECRYPT_MODE, key, spec)
108-
} catch (invalidated: KeyPermanentlyInvalidatedException) {
109-
deleteKey(alias)
110-
throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
111-
} catch (unrecoverable: UnrecoverableKeyException) {
112-
deleteKey(alias)
113-
throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
114-
}
115-
cipher
101+
initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
116102
} else if (supportsKeystoreAuth) {
117-
try {
118-
cipher.init(Cipher.DECRYPT_MODE, key, spec)
119-
} catch (invalidated: KeyPermanentlyInvalidatedException) {
120-
deleteKey(alias)
121-
throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
122-
} catch (unrecoverable: UnrecoverableKeyException) {
123-
deleteKey(alias)
124-
throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
125-
}
126-
103+
initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
127104
val authenticated = authenticator.authenticate(prompt, resolution.allowedAuthenticators, cipher)
128105
(authenticated ?: cipher)
129106
} else {
130107
authenticator.authenticate(prompt, resolution.allowedAuthenticators, null)
131-
try {
132-
cipher.init(Cipher.DECRYPT_MODE, key, spec)
133-
} catch (invalidated: KeyPermanentlyInvalidatedException) {
134-
deleteKey(alias)
135-
throw IllegalStateException("Decryption key invalidated. Item must be recreated.", invalidated)
136-
} catch (unrecoverable: UnrecoverableKeyException) {
137-
deleteKey(alias)
138-
throw IllegalStateException("Decryption key unavailable. Item must be recreated.", unrecoverable)
139-
}
140-
cipher
108+
initCipher(cipher, Cipher.DECRYPT_MODE, key, spec, alias)
141109
}
142110
} catch (error: CancellationException) {
143111
throw error
@@ -158,11 +126,30 @@ internal class CryptoManager(
158126
}
159127
}
160128

129+
private fun initCipher(
130+
cipher: Cipher,
131+
mode: Int,
132+
key: SecretKey,
133+
spec: GCMParameterSpec?,
134+
alias: String
135+
): Cipher {
136+
try {
137+
if (spec != null) cipher.init(mode, key, spec) else cipher.init(mode, key)
138+
} catch (invalidated: KeyPermanentlyInvalidatedException) {
139+
deleteKey(alias)
140+
val action = if (mode == Cipher.ENCRYPT_MODE) "Encryption" else "Decryption"
141+
throw IllegalStateException("$action key invalidated. Item must be recreated.", invalidated)
142+
} catch (unrecoverable: UnrecoverableKeyException) {
143+
deleteKey(alias)
144+
val action = if (mode == Cipher.ENCRYPT_MODE) "Encryption" else "Decryption"
145+
throw IllegalStateException("$action key unavailable. Item must be recreated.", unrecoverable)
146+
}
147+
return cipher
148+
}
149+
161150
/**
162-
* Reconstructs the resolution for data loaded from SharedPreferences.
163-
*
164-
* This lets us decrypt entries that were encrypted on a previous run without re-reading
165-
* the original access-control input, since the persisted metadata is authoritative.
151+
* Reconstructs the resolution for data loaded from SharedPreferences,
152+
* using the persisted metadata as the source of truth.
166153
*/
167154
fun buildResolutionForPersisted(
168155
accessControl: AccessControl,
@@ -213,7 +200,9 @@ internal class CryptoManager(
213200

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

251240
if (resolution.accessControl == AccessControl.DEVICEPASSCODE) {
252-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
241+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && hasSecureLockScreen()) {
253242
builder.setUnlockedDeviceRequired(true)
254243
}
255244
}

biome.json

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
33
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
44
"files": {
55
"includes": [
@@ -28,7 +28,7 @@
2828
"linter": {
2929
"enabled": true,
3030
"rules": {
31-
"recommended": true,
31+
"preset": "recommended",
3232
"correctness": {
3333
"noUnusedImports": "error",
3434
"noUnusedVariables": "error",
@@ -37,10 +37,18 @@
3737
},
3838
"style": {
3939
"useImportType": "error",
40-
"useConsistentArrayType": "error"
40+
"useConsistentArrayType": "error",
41+
"useErrorCause": "warn",
42+
"useConsistentEnumValueType": "warn"
4143
},
4244
"suspicious": {
43-
"noExplicitAny": "warn"
45+
"noExplicitAny": "warn",
46+
"noShadow": "warn",
47+
"noUnnecessaryConditions": "warn",
48+
"noNestedPromises": "error"
49+
},
50+
"security": {
51+
"noScriptUrl": "error"
4452
}
4553
}
4654
},
@@ -64,6 +72,14 @@
6472
"suspicious": { "noExplicitAny": "off" }
6573
}
6674
}
75+
},
76+
{
77+
"includes": ["src/hooks/**"],
78+
"linter": {
79+
"rules": {
80+
"suspicious": { "noUnnecessaryConditions": "off" }
81+
}
82+
}
6783
}
6884
]
6985
}

0 commit comments

Comments
 (0)