Skip to content

Commit 60b2cf5

Browse files
mCodexCopilot
andcommitted
feat: implement integrity hardening for sensitive data storage
- Added HMAC-SHA256 integrity tags to entries, enhancing tamper detection. - Introduced AES-GCM AAD for additional security against cross-entry attacks. - Implemented zeroization of plaintext buffers post-use on both platforms. - Updated error handling to include IntegrityViolationError for tampering cases. - Enhanced metadata structure to include integrity tags and AAD usage flags. - Updated tests to verify integrity tag propagation and error handling for integrity violations. Co-authored-by: Copilot <copilot@github.com>
1 parent 583bc3c commit 60b2cf5

13 files changed

Lines changed: 589 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
### Features
44

55
* **rotation:** Add versioned key rotation via `rotateKeys()` and `getKeyVersion()` with lazy re-encryption on read. New `useKeyRotation` hook exposes the same flow declaratively.
6+
* **security hardening:** Defense-in-depth pass — non-breaking, applied transparently to new writes and via lazy upgrade on rotation:
7+
- 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.
8+
- AES-GCM AAD on Android binds ciphertext to `service|key|v<version>`, defeating cross-entry swap attacks.
9+
- `setUnlockedDeviceRequired(true)` on every Android Keystore key (API 28+), mirroring iOS's `kSecAttrAccessibleWhenUnlocked` semantics.
10+
- Plaintext byte buffers are zeroized after use on both platforms.
11+
- Constant-time HMAC comparison via `MessageDigest.isEqual` / manual `UInt8` XOR fold.
12+
- Backwards compatible: entries written by earlier versions decode without verification and are upgraded on the next write or rotation.
613
* **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.
714
* **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.
815
* **nitro 0.35:** Regenerated against `nitrogen@0.35.5` and `react-native-nitro-modules@0.35.5`.

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,15 @@ function RotationButton() {
308308
| --- | --- | --- |
309309
| Master key | Android Keystore (`AES/GCM`, StrongBox when available) | Secure Enclave-gated (P-256) + AES-GCM |
310310
| Authentication | BiometricPrompt (Class 3 preferred), device credential fallback | LAContext / Face ID / Touch ID / Optic ID |
311-
| At-rest integrity | AES-GCM authentication tag | AES-GCM authentication tag |
311+
| At-rest integrity | AES-GCM tag **+** HMAC-SHA256 metadata tag (Keystore-bound) | AES-GCM tag **+** HMAC-SHA256 metadata tag (Keychain-stored, after-first-unlock) |
312+
| Replay / swap defense | AES-GCM AAD bound to `service\|key\|v<version>` | Keychain `kSecAttrService` + `kSecAttrAccount` binding |
313+
| Device-state gating | `setUnlockedDeviceRequired(true)` on every key (API 28+) | `kSecAttrAccessibleWhenUnlocked*` defaults |
314+
| Plaintext lifetime | Buffers zeroized after encrypt/decrypt | `Data` buffers zeroized via `memset_s` |
312315
| Key rotation | Versioned Keystore aliases, lazy re-encryption | Versioned Keychain metadata, lazy re-wrap (preserves original access control) |
313316
| Error classification | Typed `SensitiveInfoError` subclasses via `/errors` subpath | Same |
314317

318+
> **Tamper detection:** every read recomputes the HMAC over the persisted `(service, key, version, accessControl, securityLevel, timestamp, ciphertext, iv)` tuple. A mismatch raises `IntegrityViolationError` (`E_INTEGRITY_VIOLATION`) **before** any biometric prompt fires, so spoofed entries can never trigger user authentication. Entries written by older library versions (no `integrityTag`) are accepted on first read and upgraded on the next write or rotation.
319+
315320
Typed errors can be imported from the `/errors` subpath for tree-shakeable error handling:
316321

317322
```tsx

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

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@ import com.sensitiveinfo.internal.auth.BiometricAuthenticator
1010
import com.sensitiveinfo.internal.crypto.AccessControlResolver
1111
import com.sensitiveinfo.internal.crypto.AccessResolution
1212
import com.sensitiveinfo.internal.crypto.CryptoManager
13+
import com.sensitiveinfo.internal.crypto.IntegrityInput
14+
import com.sensitiveinfo.internal.crypto.MetadataIntegrity
1315
import com.sensitiveinfo.internal.crypto.SecurityAvailabilityResolver
1416
import com.sensitiveinfo.internal.storage.KeyVersionRegistry
1517
import com.sensitiveinfo.internal.storage.PersistedEntry
1618
import com.sensitiveinfo.internal.storage.PersistedMetadata
1719
import com.sensitiveinfo.internal.storage.SecureStorage
1820
import com.sensitiveinfo.internal.util.AliasGenerator
1921
import com.sensitiveinfo.internal.util.ReactContextHolder
22+
import com.sensitiveinfo.internal.util.SensitiveInfoException
2023
import com.sensitiveinfo.internal.util.ServiceNameResolver
24+
import com.sensitiveinfo.internal.util.persistedName
2125
import kotlinx.coroutines.CoroutineScope
2226
import kotlinx.coroutines.Dispatchers
2327
import kotlinx.coroutines.SupervisorJob
@@ -41,7 +45,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
4145
val accessControlResolver: AccessControlResolver,
4246
val securityAvailabilityResolver: SecurityAvailabilityResolver,
4347
val serviceNameResolver: ServiceNameResolver,
44-
val keyVersionRegistry: KeyVersionRegistry
48+
val keyVersionRegistry: KeyVersionRegistry,
49+
val integrity: MetadataIntegrity
4550
)
4651

4752
@Volatile
@@ -67,7 +72,8 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
6772
accessControlResolver = accessControlResolver,
6873
securityAvailabilityResolver = securityAvailabilityResolver,
6974
serviceNameResolver = serviceNameResolver,
70-
keyVersionRegistry = KeyVersionRegistry(ctx)
75+
keyVersionRegistry = KeyVersionRegistry(ctx),
76+
integrity = MetadataIntegrity()
7177
).also { built ->
7278
dependencies = built
7379
}
@@ -84,10 +90,26 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
8490
val alias = AliasGenerator.aliasFor(service, request.key, version)
8591

8692
val plaintext = request.value.toByteArray(Charsets.UTF_8)
87-
val encryption = deps.cryptoManager.encrypt(alias, plaintext, resolved, request.authenticationPrompt)
93+
val aad = aadFor(service, request.key, version)
94+
val encryption = deps.cryptoManager.encrypt(
95+
alias, plaintext, resolved, request.authenticationPrompt, aad
96+
)
8897

89-
val metadata = buildMetadata(resolved.securityLevel, resolved.accessControl, version)
90-
val entry = buildEntry(alias, encryption.ciphertext, encryption.iv, metadata, resolved, version)
98+
val timestamp = System.currentTimeMillis() / 1000.0
99+
val tag = deps.integrity.sign(
100+
integrityInputFor(
101+
service, request.key, version,
102+
resolved.accessControl, resolved.securityLevel,
103+
timestamp, encryption.iv, encryption.ciphertext
104+
)
105+
)
106+
val metadata = buildMetadata(
107+
resolved.securityLevel, resolved.accessControl, version, timestamp, tag
108+
)
109+
val entry = buildEntry(
110+
alias, encryption.ciphertext, encryption.iv, metadata, resolved, version,
111+
usesAad = true, integrityTag = tag
112+
)
91113

92114
deps.storage.save(service, request.key, entry)
93115

@@ -104,7 +126,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
104126
?: return@async emptyItem(request.key, service)
105127

106128
val includeValue = request.includeValue == true
107-
val decrypted = if (includeValue) decryptEntry(deps, entry, request.authenticationPrompt) else null
129+
val decrypted = if (includeValue) decryptEntry(deps, entry, request.authenticationPrompt, service, request.key) else null
108130
val upgraded = if (includeValue && decrypted != null) {
109131
maybeReEncrypt(deps, service, request.key, entry, decrypted, request.authenticationPrompt)
110132
} else {
@@ -155,7 +177,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
155177
entries.mapNotNull { (key, entry) ->
156178
try {
157179
val value = if (includeValues) {
158-
runCatching { decryptEntry(deps, entry, request?.authenticationPrompt) }.getOrNull()
180+
runCatching { decryptEntry(deps, entry, request?.authenticationPrompt, service, key) }.getOrNull()
159181
} else {
160182
null
161183
}
@@ -240,36 +262,66 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
240262
private fun buildMetadata(
241263
securityLevel: SecurityLevel,
242264
accessControl: AccessControl,
243-
keyVersion: Int
265+
keyVersion: Int,
266+
timestamp: Double = System.currentTimeMillis() / 1000.0,
267+
integrityTag: String? = null
244268
): StorageMetadata = StorageMetadata(
245269
securityLevel = securityLevel,
246270
backend = StorageBackend.ANDROIDKEYSTORE,
247271
accessControl = accessControl,
248-
timestamp = System.currentTimeMillis() / 1000.0,
272+
timestamp = timestamp,
249273
keyVersion = keyVersion.toDouble(),
250-
integrityTag = null
274+
integrityTag = integrityTag
251275
)
252276

253277
private fun fallbackMetadata(keyVersion: Int = KeyVersionRegistry.INITIAL_VERSION): StorageMetadata =
254278
buildMetadata(SecurityLevel.SOFTWARE, AccessControl.NONE, keyVersion)
255279

280+
private fun aadFor(service: String, key: String, version: Int): ByteArray =
281+
"$service|$key|v$version".toByteArray(Charsets.UTF_8)
282+
283+
/** Single source of truth for HMAC integrity inputs. */
284+
private fun integrityInputFor(
285+
service: String,
286+
key: String,
287+
version: Int,
288+
accessControl: AccessControl,
289+
securityLevel: SecurityLevel,
290+
timestamp: Double,
291+
iv: ByteArray,
292+
ciphertext: ByteArray
293+
): IntegrityInput = IntegrityInput(
294+
service = service,
295+
key = key,
296+
keyVersion = version,
297+
accessControl = accessControl.persistedName(),
298+
securityLevel = securityLevel.persistedName(),
299+
timestamp = timestamp,
300+
iv = iv,
301+
ciphertext = ciphertext
302+
)
303+
256304
private fun buildEntry(
257305
alias: String,
258306
ciphertext: ByteArray,
259307
iv: ByteArray,
260308
metadata: StorageMetadata,
261309
resolved: AccessResolution,
262-
keyVersion: Int
310+
keyVersion: Int,
311+
usesAad: Boolean = false,
312+
integrityTag: String? = null
263313
): PersistedEntry = PersistedEntry(
264314
alias = alias,
265315
ciphertext = ciphertext,
266316
iv = iv,
267-
metadata = PersistedMetadata.from(metadata),
317+
metadata = PersistedMetadata.from(metadata, integrityTag),
268318
authenticators = resolved.allowedAuthenticators,
269319
requiresAuthentication = resolved.requiresAuthentication,
270320
invalidateOnEnrollment = resolved.invalidateOnEnrollment,
271321
useStrongBox = resolved.useStrongBox,
272-
keyVersion = keyVersion
322+
keyVersion = keyVersion,
323+
usesAad = usesAad,
324+
integrityTag = integrityTag
273325
)
274326

275327
private fun emptyItem(key: String, service: String): Variant_NullType_SensitiveInfoItem {
@@ -292,7 +344,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
292344
private suspend fun decryptEntry(
293345
deps: Dependencies,
294346
entry: PersistedEntry,
295-
prompt: AuthenticationPrompt?
347+
prompt: AuthenticationPrompt?,
348+
service: String,
349+
key: String
296350
): String? {
297351
if (entry.ciphertext == null || entry.iv == null) return null
298352
val metadata = entry.metadata.toStorageMetadata()
@@ -304,8 +358,33 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
304358
invalidateOnEnrollment = entry.invalidateOnEnrollment,
305359
useStrongBox = entry.useStrongBox
306360
)
307-
val plaintext = deps.cryptoManager.decrypt(entry.alias, entry.ciphertext, entry.iv, resolution, prompt)
308-
return String(plaintext, Charsets.UTF_8)
361+
362+
// Verify integrity *before* decrypting so a tampered envelope never reaches AES-GCM and never
363+
// triggers a biometric prompt. Legacy entries (integrityTag == null) are accepted and will be
364+
// upgraded on next write/rotation.
365+
if (entry.integrityTag != null && metadata != null) {
366+
val ok = deps.integrity.verify(
367+
integrityInputFor(
368+
service, key, entry.keyVersion,
369+
metadata.accessControl, metadata.securityLevel,
370+
metadata.timestamp, entry.iv, entry.ciphertext
371+
),
372+
entry.integrityTag
373+
)
374+
if (!ok) {
375+
throw SensitiveInfoException.IntegrityViolation(key, service)
376+
}
377+
}
378+
379+
val aad = if (entry.usesAad) aadFor(service, key, entry.keyVersion) else null
380+
val plaintext = deps.cryptoManager.decrypt(
381+
entry.alias, entry.ciphertext, entry.iv, resolution, prompt, aad
382+
)
383+
return try {
384+
String(plaintext, Charsets.UTF_8)
385+
} finally {
386+
plaintext.fill(0)
387+
}
309388
}
310389

311390
private suspend fun maybeReEncrypt(
@@ -343,9 +422,25 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
343422
invalidateOnEnrollment = entry.invalidateOnEnrollment,
344423
useStrongBox = entry.useStrongBox
345424
)
346-
val encryption = deps.cryptoManager.encrypt(newAlias, plaintext.toByteArray(Charsets.UTF_8), resolved, prompt)
347-
val metadata = buildMetadata(resolved.securityLevel, resolved.accessControl, targetVersion)
348-
val upgraded = buildEntry(newAlias, encryption.ciphertext, encryption.iv, metadata, resolved, targetVersion)
425+
val encryption = deps.cryptoManager.encrypt(
426+
newAlias, plaintext.toByteArray(Charsets.UTF_8), resolved, prompt,
427+
aadFor(service, key, targetVersion)
428+
)
429+
val timestamp = System.currentTimeMillis() / 1000.0
430+
val tag = deps.integrity.sign(
431+
integrityInputFor(
432+
service, key, targetVersion,
433+
resolved.accessControl, resolved.securityLevel,
434+
timestamp, encryption.iv, encryption.ciphertext
435+
)
436+
)
437+
val metadata = buildMetadata(
438+
resolved.securityLevel, resolved.accessControl, targetVersion, timestamp, tag
439+
)
440+
val upgraded = buildEntry(
441+
newAlias, encryption.ciphertext, encryption.iv, metadata, resolved, targetVersion,
442+
usesAad = true, integrityTag = tag
443+
)
349444
deps.storage.save(service, key, upgraded)
350445
if (newAlias != entry.alias) {
351446
deps.cryptoManager.deleteKey(entry.alias)
@@ -362,7 +457,7 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
362457
var count = 0
363458
for ((key, entry) in deps.storage.readAll(service)) {
364459
if (entry.keyVersion >= targetVersion) continue
365-
val plaintext = runCatching { decryptEntry(deps, entry, prompt) }.getOrNull() ?: continue
460+
val plaintext = runCatching { decryptEntry(deps, entry, prompt, service, key) }.getOrNull() ?: continue
366461
runCatching {
367462
reEncryptEntry(deps, service, key, entry, plaintext, targetVersion, prompt)
368463
count += 1

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ internal class CryptoManager(
3939
alias: String,
4040
plaintext: ByteArray,
4141
resolution: AccessResolution,
42-
prompt: AuthenticationPrompt?
42+
prompt: AuthenticationPrompt?,
43+
aad: ByteArray? = null
4344
): EncryptionResult {
4445
val key = getOrCreateKey(alias, resolution)
4546
val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -74,8 +75,15 @@ internal class CryptoManager(
7475
throw error
7576
}
7677

77-
val ciphertext = readyCipher.doFinal(plaintext)
78-
return EncryptionResult(ciphertext = ciphertext, iv = readyCipher.iv)
78+
if (aad != null) {
79+
readyCipher.updateAAD(aad)
80+
}
81+
return try {
82+
val ciphertext = readyCipher.doFinal(plaintext)
83+
EncryptionResult(ciphertext = ciphertext, iv = readyCipher.iv)
84+
} finally {
85+
plaintext.fill(0)
86+
}
7987
}
8088

8189
/** Decrypts an item using the preconfigured alias, IV, and policy. */
@@ -84,7 +92,8 @@ internal class CryptoManager(
8492
ciphertext: ByteArray,
8593
iv: ByteArray,
8694
resolution: AccessResolution,
87-
prompt: AuthenticationPrompt?
95+
prompt: AuthenticationPrompt?,
96+
aad: ByteArray? = null
8897
): ByteArray {
8998
val key = getOrCreateKey(alias, resolution)
9099
val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -134,6 +143,9 @@ internal class CryptoManager(
134143
throw error
135144
}
136145

146+
if (aad != null) {
147+
readyCipher.updateAAD(aad)
148+
}
137149
return readyCipher.doFinal(ciphertext)
138150
}
139151

@@ -199,6 +211,16 @@ internal class CryptoManager(
199211
}
200212
}
201213

214+
// Defense in depth: require the device to be unlocked at the moment of use, mirroring iOS's
215+
// `kSecAttrAccessibleWhenUnlocked` default. Available on API 28+.
216+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
217+
try {
218+
builder.setUnlockedDeviceRequired(true)
219+
} catch (_: Throwable) {
220+
// Older OEM forks may reject this on devices without a screen lock. Best-effort.
221+
}
222+
}
223+
202224
val spec = builder.build()
203225
keyGenerator.init(spec)
204226
return try {

0 commit comments

Comments
 (0)