Skip to content

Commit 0282703

Browse files
committed
Add documentation and improve error handling for SensitiveInfo
Added detailed KDoc and Swift doc comments to core SensitiveInfo classes and methods for both Android and iOS, improving maintainability and developer understanding. Introduced custom exception handling for not-found errors on Android, with matching error mapping in the TypeScript bridge to return null for missing items. Updated the example app to better handle value inclusion and UI state. These changes enhance cross-platform consistency, error reporting, and code clarity.
1 parent 676034b commit 0282703

19 files changed

Lines changed: 171 additions & 10 deletions

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,28 @@ import com.sensitiveinfo.internal.storage.PersistedMetadata
2525
import com.sensitiveinfo.internal.storage.SecureStorage
2626
import com.sensitiveinfo.internal.util.AliasGenerator
2727
import com.sensitiveinfo.internal.util.ReactContextHolder
28+
import com.sensitiveinfo.internal.util.SensitiveInfoException
2829
import com.sensitiveinfo.internal.util.ServiceNameResolver
2930
import com.sensitiveinfo.internal.util.accessControlFromPersisted
3031
import com.sensitiveinfo.internal.util.storageBackendFromPersisted
3132
import kotlinx.coroutines.Dispatchers
3233
import kotlinx.coroutines.withContext
3334
import kotlin.text.Charsets
3435

36+
/**
37+
* Android implementation of the SensitiveInfo Nitro module.
38+
*
39+
* All calls happen on Nitro managed promises, so the JS side can simply import the generated
40+
* functions:
41+
*
42+
* ```ts
43+
* import { setItem } from 'react-native-sensitive-info'
44+
* await setItem('bank-pin', '1234', { accessControl: 'secureEnclaveBiometry' })
45+
* ```
46+
*
47+
* The class resolves the appropriate storage backend, encrypts values with the Android Keystore,
48+
* and keeps metadata so JavaScript consumers always know which security tier saved an entry.
49+
*/
3550
class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
3651
private val applicationContext get() = ReactContextHolder.requireContext()
3752

@@ -42,6 +57,11 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
4257
private val authenticator by lazy { BiometricAuthenticator() }
4358
private val cryptoManager by lazy { CryptoManager(authenticator) }
4459

60+
/**
61+
* Encrypts and stores a secret for the requested service/key pair.
62+
*
63+
* @return Metadata describing the security level used, mirroring the JS `MutationResult` type.
64+
*/
4565
override fun setItem(request: SensitiveInfoSetRequest): Promise<MutationResult> {
4666
return Promise.async {
4767
val service = serviceResolver.resolve(request.service)
@@ -90,11 +110,15 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
90110
}
91111
}
92112

113+
/**
114+
* Reads a single item; optionally decrypts the payload if JS requested the plaintext value.
115+
*/
93116
override fun getItem(request: SensitiveInfoGetRequest): Promise<SensitiveInfoItem?> {
94117
return Promise.async {
95118
val includeValue = request.includeValue ?: true
96119
val service = serviceResolver.resolve(request.service)
97-
val entry = withContext(Dispatchers.IO) { storage.read(service, request.key) } ?: return@async null
120+
val entry = withContext(Dispatchers.IO) { storage.read(service, request.key) }
121+
?: throw SensitiveInfoException.NotFound(request.key, service)
98122

99123
val metadata = entry.metadata.toStorageMetadata() ?: fallbackMetadata(entry)
100124

@@ -113,6 +137,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
113137
}
114138
}
115139

140+
/**
141+
* Deletes a saved secret. If the underlying keystore alias becomes unused we dispose it as well.
142+
*/
116143
override fun deleteItem(request: SensitiveInfoDeleteRequest): Promise<Boolean> {
117144
return Promise.async {
118145
val service = serviceResolver.resolve(request.service)
@@ -125,6 +152,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
125152
}
126153
}
127154

155+
/**
156+
* Lightweight existence check backed by the SharedPreferences metadata store.
157+
*/
128158
override fun hasItem(request: SensitiveInfoHasRequest): Promise<Boolean> {
129159
return Promise.async {
130160
val service = serviceResolver.resolve(request.service)
@@ -134,6 +164,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
134164
}
135165
}
136166

167+
/**
168+
* Enumerates every entry in a service. When `includeValues` is false the secrets stay encrypted.
169+
*/
137170
override fun getAllItems(request: SensitiveInfoEnumerateRequest?): Promise<Array<SensitiveInfoItem>> {
138171
return Promise.async {
139172
val includeValues = request?.includeValues == true
@@ -160,6 +193,9 @@ class HybridSensitiveInfo : HybridSensitiveInfoSpec() {
160193
}
161194
}
162195

196+
/**
197+
* Clears an entire service namespace and purges any orphaned keystore aliases.
198+
*/
163199
override fun clearService(request: SensitiveInfoOptions?): Promise<Unit> {
164200
return Promise.async {
165201
val service = serviceResolver.resolve(request?.service)

android/src/main/java/com/sensitiveinfo/internal/auth/BiometricAuthenticator.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import javax.crypto.Cipher
1414
import kotlin.coroutines.resume
1515
import kotlin.coroutines.resumeWithException
1616

17+
/**
18+
* Coroutine-friendly wrapper around `BiometricPrompt` used by the Keystore flows.
19+
*
20+
* The helper always executes prompts on the main dispatcher and returns the cipher configured for
21+
* the successful authentication. Cancellation propagates back to the calling coroutine, matching
22+
* the surface used by the Nitro Promise bridge.
23+
*/
1724
internal class BiometricAuthenticator {
1825
suspend fun authenticate(
1926
prompt: AuthenticationPrompt?,
@@ -84,6 +91,7 @@ internal class BiometricAuthenticator {
8491
}
8592
} else {
8693
if (allowsDeviceCredential) {
94+
@Suppress("DEPRECATION")
8795
builder.setDeviceCredentialAllowed(true)
8896
} else {
8997
builder.setNegativeButtonText(prompt?.cancel ?: DEFAULT_CANCEL)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import androidx.biometric.BiometricManager.Authenticators
44
import com.margelo.nitro.sensitiveinfo.AccessControl
55
import com.margelo.nitro.sensitiveinfo.SecurityLevel
66

7+
/**
8+
* Determines which Android security primitives should back a requested access control.
9+
*
10+
* The resolver walks through a preference list, discarding options that are unavailable on the
11+
* current device (e.g. no StrongBox, weak biometrics only). The resulting `AccessResolution`
12+
* instructs the `CryptoManager` how to create or reopen the matching keystore key.
13+
*/
714
internal class AccessControlResolver(
815
private val availabilityResolver: SecurityAvailabilityResolver
916
) {
@@ -15,6 +22,7 @@ internal class AccessControlResolver(
1522
AccessControl.NONE
1623
)
1724

25+
/** Chooses the best available policy given the caller preference and hardware capabilities. */
1826
fun resolve(preferred: AccessControl?, strongBiometricsOnly: Boolean): AccessResolution {
1927
val availability = availabilityResolver.resolve()
2028
val ordered = orderPreferences(preferred)

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,20 @@ import javax.crypto.spec.GCMParameterSpec
2020
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
2121
private const val TRANSFORMATION = "AES/GCM/NoPadding"
2222

23+
/**
24+
* Handles Android Keystore interactions (AES/GCM keys, biometric gating, alias cleanup).
25+
*
26+
* Callers supply an alias and the resolved `AccessResolution` (which captures whether StrongBox,
27+
* biometrics, or device credentials are required). The manager takes care of provisioning keys,
28+
* invoking the biometric prompt, and transparently rebuilding the resolution for entries fetched
29+
* from disk.
30+
*/
2331
internal class CryptoManager(
2432
private val authenticator: BiometricAuthenticator
2533
) {
2634
private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
2735

36+
/** Encrypts data and returns the ciphertext plus generated IV. */
2837
suspend fun encrypt(
2938
alias: String,
3039
plaintext: ByteArray,
@@ -51,6 +60,7 @@ internal class CryptoManager(
5160
return EncryptionResult(ciphertext = ciphertext, iv = readyCipher.iv)
5261
}
5362

63+
/** Decrypts an item using the preconfigured alias, IV, and policy. */
5464
suspend fun decrypt(
5565
alias: String,
5666
ciphertext: ByteArray,
@@ -81,6 +91,7 @@ internal class CryptoManager(
8191
return readyCipher.doFinal(ciphertext)
8292
}
8393

94+
/** Best-effort removal of a keystore entry. */
8495
fun deleteKey(alias: String) {
8596
try {
8697
keyStore.deleteEntry(alias)
@@ -89,6 +100,12 @@ internal class CryptoManager(
89100
}
90101
}
91102

103+
/**
104+
* Reconstructs the resolution for data loaded from SharedPreferences.
105+
*
106+
* This lets us decrypt entries that were encrypted on a previous run without re-reading
107+
* the original access-control input, since the persisted metadata is authoritative.
108+
*/
92109
fun buildResolutionForPersisted(
93110
accessControl: AccessControl,
94111
securityLevel: SecurityLevel,
@@ -155,6 +172,7 @@ internal class CryptoManager(
155172
builder.setUserAuthenticationParameters(0, sanitized)
156173
} else {
157174
builder.setUserAuthenticationRequired(true)
175+
@Suppress("DEPRECATION")
158176
builder.setUserAuthenticationValidityDurationSeconds(1)
159177
}
160178

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ internal data class SecurityAvailabilitySnapshot(
1616
val deviceCredential: Boolean
1717
)
1818

19+
/**
20+
* Caches hardware capability checks (StrongBox, biometrics, device credential).
21+
*
22+
* The Android system calls here can be relatively expensive, so we memoize the result until the
23+
* process restarts. JS can always request a fresh snapshot by calling
24+
* `getSupportedSecurityLevels()`.
25+
*/
1926
internal class SecurityAvailabilityResolver(private val context: Context) {
2027
private val lock = ReentrantLock()
2128
private var cached: SecurityAvailabilitySnapshot? = null

android/src/main/java/com/sensitiveinfo/internal/storage/PersistedEntry.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ package com.sensitiveinfo.internal.storage
33
import org.json.JSONObject
44
import android.util.Base64
55

6+
/**
7+
* Serialized representation of an entry in SharedPreferences.
8+
*
9+
* `ciphertext` and `iv` remain optional so callers can cache metadata-only items (for example
10+
* when a secret is hardware-gated and the user has not authenticated yet).
11+
*/
612
internal data class PersistedEntry(
713
val alias: String,
814
val ciphertext: ByteArray?,

android/src/main/java/com/sensitiveinfo/internal/storage/PersistedMetadata.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.sensitiveinfo.internal.util.persistedName
99
import com.sensitiveinfo.internal.util.securityLevelFromPersisted
1010
import com.sensitiveinfo.internal.util.storageBackendFromPersisted
1111

12+
/** Mirrors the TypeScript `StorageMetadata` shape so we can round-trip metadata through JSON. */
1213
internal data class PersistedMetadata(
1314
val securityLevel: String,
1415
val backend: String,

android/src/main/java/com/sensitiveinfo/internal/storage/SecureStorage.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import android.content.Context
44
import android.content.SharedPreferences
55
import com.sensitiveinfo.internal.util.ServiceNameResolver
66

7+
/**
8+
* Thin SharedPreferences wrapper. The real secret is stored in the Keystore; this component keeps
9+
* the encrypted payload, IV, and metadata JSON on disk so we can enumerate entries cheaply.
10+
*/
711
internal class SecureStorage(context: Context) {
812
private val resolver = ServiceNameResolver(context)
913
private val applicationContext = context.applicationContext

android/src/main/java/com/sensitiveinfo/internal/util/AliasGenerator.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ package com.sensitiveinfo.internal.util
33
import java.security.MessageDigest
44
import java.util.Locale
55

6+
/**
7+
* Produces deterministic Android Keystore alias names.
8+
*
9+
* Aliases follow the pattern `SensitiveInfo_v1_<serviceHash>_<policySignature>` so rotating
10+
* security policies for a service results in new keys while keeping the old ones around for
11+
* existing entries. The service hash keeps names short and filesystem safe.
12+
*/
613
internal object AliasGenerator {
714
private const val PREFIX = "SensitiveInfo_v1"
815

@@ -11,6 +18,6 @@ internal object AliasGenerator {
1118
val hash = digest.take(8).joinToString(separator = "") { byte ->
1219
String.format(Locale.US, "%02x", byte)
1320
}
14-
return "$PREFIX_$hash_${accessSignature.lowercase(Locale.US)}"
21+
return "${PREFIX}_${hash}_${accessSignature.lowercase(Locale.US)}"
1522
}
1623
}

android/src/main/java/com/sensitiveinfo/internal/util/EnumInterop.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.margelo.nitro.sensitiveinfo.AccessControl
44
import com.margelo.nitro.sensitiveinfo.SecurityLevel
55
import com.margelo.nitro.sensitiveinfo.StorageBackend
66

7+
/** Ensures enums round-trip between Kotlin and the generated TypeScript string literal unions. */
78
internal fun AccessControl.persistedName(): String = when (this) {
89
AccessControl.SECUREENCLAVEBIOMETRY -> "secureEnclaveBiometry"
910
AccessControl.BIOMETRYCURRENTSET -> "biometryCurrentSet"

0 commit comments

Comments
 (0)