Skip to content

Commit 19a4d56

Browse files
committed
Improve activity resolution and authentication handling
Refactored activity resolution to use ActivityContextHolder as a fallback throughout the sensitive info module and internal classes, improving reliability when currentActivity is unavailable. Enhanced CryptoManager to support universal authentication flows for both encryption and decryption, handling Android version differences. Moved StorageResult to its own file and updated usages for clarity. Improved biometric authentication handling for API 28 and below, ensuring consistent user prompts and fallback logic.
1 parent 2f75415 commit 19a4d56

File tree

7 files changed

+247
-127
lines changed

7 files changed

+247
-127
lines changed

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.facebook.react.bridge.Arguments
99
import com.sensitiveinfo.internal.HybridSensitiveInfo
1010
import com.sensitiveinfo.internal.auth.AuthenticationPrompt
1111
import com.sensitiveinfo.internal.util.SensitiveInfoException
12+
import com.sensitiveinfo.internal.util.ActivityContextHolder
1213
import androidx.fragment.app.FragmentActivity
1314
import kotlinx.coroutines.CoroutineScope
1415
import kotlinx.coroutines.Dispatchers
@@ -104,12 +105,26 @@ import kotlinx.coroutines.launch
104105
class SensitiveInfoModule(reactContext: ReactApplicationContext) :
105106
ReactContextBaseJavaModule(reactContext) {
106107

107-
private val sensitiveInfo: HybridSensitiveInfo
108108
private val coroutineScope = CoroutineScope(Dispatchers.Main)
109109

110-
init {
110+
/**
111+
* Lazy initialization of HybridSensitiveInfo with runtime activity resolution.
112+
*
113+
* **Why lazy?**
114+
* - reactContext.currentActivity might be null at module initialization
115+
* - Activity becomes available after the app starts
116+
* - We get the activity dynamically when first needed
117+
*
118+
* **Activity Resolution**:
119+
* 1. First try: reactContext.currentActivity
120+
* 2. Fallback: ActivityContextHolder.getActivity()
121+
* 3. If both null: BiometricAuthenticator will handle gracefully
122+
*/
123+
private val sensitiveInfo: HybridSensitiveInfo by lazy {
111124
val activity = reactContext.currentActivity as? FragmentActivity
112-
sensitiveInfo = HybridSensitiveInfo(
125+
?: ActivityContextHolder.getActivity()
126+
127+
HybridSensitiveInfo(
113128
context = reactContext,
114129
activity = activity
115130
)

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

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -285,18 +285,8 @@ class HybridSensitiveInfo(
285285
}
286286

287287
return try {
288-
val storageResult = storage.getItem(key, service)
289-
storageResult?.let { result ->
290-
StorageResult(
291-
value = result.value,
292-
metadata = StorageMetadata(
293-
securityLevel = result.metadata.securityLevel,
294-
accessControl = result.metadata.accessControl,
295-
backend = result.metadata.backend,
296-
timestamp = result.metadata.timestamp
297-
)
298-
)
299-
}
288+
// storage.getItem() already returns StorageResult with metadata
289+
storage.getItem(key, service)
300290
} catch (e: SensitiveInfoException) {
301291
throw e
302292
} catch (e: Exception) {

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.biometric.BiometricManager.Authenticators
77
import androidx.biometric.BiometricPrompt
88
import androidx.fragment.app.FragmentActivity
99
import com.sensitiveinfo.internal.util.SensitiveInfoException
10+
import com.sensitiveinfo.internal.util.ActivityContextHolder
1011
import java.util.concurrent.Executor
1112
import java.util.concurrent.Executors
1213
import javax.crypto.Cipher
@@ -256,25 +257,36 @@ internal class BiometricAuthenticator(
256257
cipher: Cipher? = null,
257258
allowDeviceCredential: Boolean = true
258259
): Cipher? {
260+
// Get the current activity (from constructor or from ActivityContextHolder)
261+
val currentActivity = activity ?: ActivityContextHolder.getActivity()
262+
263+
if (currentActivity == null) {
264+
throw SensitiveInfoException.ActivityUnavailable(
265+
"FragmentActivity not available. " +
266+
"Make sure to call ActivityContextHolder.setActivity() from MainActivity.onCreate(). " +
267+
"Error: Authentication required an active fragmentActivity"
268+
)
269+
}
270+
259271
// Determine which authentication method to use based on API level and availability
260272
val supportsInlineDeviceCredential = allowDeviceCredential && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
261273
val allowLegacyDeviceCredential = allowDeviceCredential && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
262274

263275
// Special handling for API 28 (Android 9): Biometric may not be available
264276
if (allowLegacyDeviceCredential && !canUseBiometric()) {
265277
// Biometric not available, try device credential manually
266-
val success = DeviceCredentialPromptFragment.authenticate(activity, prompt)
278+
val success = DeviceCredentialPromptFragment.authenticate(currentActivity, prompt)
267279
if (success) return cipher
268280
throw SensitiveInfoException.AuthenticationCanceled()
269281
}
270282

271283
// Try BiometricPrompt (primary method for API 30+)
272284
return try {
273-
authenticateWithBiometricPrompt(prompt, cipher, supportsInlineDeviceCredential)
285+
authenticateWithBiometricPrompt(currentActivity, prompt, cipher, supportsInlineDeviceCredential)
274286
} catch (error: SensitiveInfoException) {
275287
// BiometricPrompt failed, try legacy device credential fallback
276288
if (allowLegacyDeviceCredential && error !is SensitiveInfoException.AuthenticationCanceled) {
277-
val success = DeviceCredentialPromptFragment.authenticate(activity, prompt)
289+
val success = DeviceCredentialPromptFragment.authenticate(currentActivity, prompt)
278290
if (success) return cipher
279291
}
280292
throw error
@@ -301,6 +313,7 @@ internal class BiometricAuthenticator(
301313
* - API 30: Can use BIOMETRIC_STRONG | DEVICE_CREDENTIAL
302314
* - API 28-29: Only BIOMETRIC_STRONG available (no official device credential in BiometricPrompt)
303315
*
316+
* @param fragmentActivity Required FragmentActivity for BiometricPrompt rendering
304317
* @param prompt User-facing prompt configuration
305318
* @param cipher Optional cipher to authenticate
306319
* @param supportsInlineDeviceCredential Whether to allow device credential in BiometricPrompt
@@ -309,6 +322,7 @@ internal class BiometricAuthenticator(
309322
* @throws SensitiveInfoException on authentication failure
310323
*/
311324
private suspend fun authenticateWithBiometricPrompt(
325+
fragmentActivity: FragmentActivity,
312326
prompt: AuthenticationPrompt,
313327
cipher: Cipher?,
314328
supportsInlineDeviceCredential: Boolean
@@ -394,7 +408,7 @@ internal class BiometricAuthenticator(
394408
}
395409

396410
// Create and show BiometricPrompt
397-
val biometricPrompt = BiometricPrompt(activity, executor, callback)
411+
val biometricPrompt = BiometricPrompt(fragmentActivity, executor, callback)
398412

399413
// Handle coroutine cancellation (user dismisses prompt)
400414
continuation.invokeOnCancellation {

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

Lines changed: 103 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,18 @@ internal class CryptoManager(
4646
*
4747
* **Workflow:**
4848
* 1. Get or create key using the resolution
49-
* 2. Initialize AES-GCM cipher (generates random IV)
50-
* 3. If authentication required, show BiometricPrompt
51-
* 4. Encrypt data
52-
* 5. Return ciphertext + IV
49+
* 2. Determine authentication strategy based on Android version
50+
* 3. If authentication required:
51+
* - **Android 10+ (API 29+)** → Authenticate *with* a cipher (keystore gated)
52+
* - **Android 7-9 (API 24-28)** → Authenticate *before* crypto (app gated)
53+
* 4. Initialize AES-GCM cipher and encrypt
54+
* 5. Return ciphertext + IV (12-byte GCM nonce)
55+
*
56+
* **Universal Authentication Model**:
57+
* - Android 10+ benefits from hardware-backed key authentication via `CryptoObject`
58+
* - Android 7-9 perform a manual biometric/credential prompt before using the key
59+
* - Both paths guarantee that the caller experiences an authentication UI whenever
60+
* `AccessResolution.requiresAuthentication == true`
5361
*
5462
* @param alias Unique key identifier
5563
* @param plaintext Data to encrypt (UTF-8 string converted to bytes)
@@ -70,36 +78,54 @@ internal class CryptoManager(
7078
// Step 1: Get or create key with proper authentication configuration
7179
val key = getOrCreateKey(alias, resolution)
7280

73-
// Step 2: Initialize cipher (generates random IV automatically)
81+
// Step 2: Prepare cipher and authentication strategy
7482
val cipher = Cipher.getInstance(TRANSFORMATION)
75-
cipher.init(Cipher.ENCRYPT_MODE, key)
83+
val requiresAuth = resolution.requiresAuthentication
84+
val supportsKeystoreAuth = requiresAuth && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
85+
val deviceCredentialAllowed =
86+
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
87+
val resolvedPrompt = prompt ?: AuthenticationPrompt(title = "Authenticate")
7688

77-
// Step 3: Get the IV that was generated
78-
val iv = cipher.iv
89+
val workingCipher = when {
90+
!requiresAuth -> {
91+
cipher.init(Cipher.ENCRYPT_MODE, key)
92+
cipher
93+
}
94+
supportsKeystoreAuth -> {
95+
val auth = authenticator ?: throw SensitiveInfoException.EncryptionFailed(
96+
"Biometric authenticator unavailable",
97+
IllegalStateException("No authenticator configured")
98+
)
99+
cipher.init(Cipher.ENCRYPT_MODE, key)
100+
val authenticatedCipher = auth.authenticate(
101+
prompt = resolvedPrompt,
102+
cipher = cipher,
103+
allowDeviceCredential = deviceCredentialAllowed
104+
) ?: cipher
105+
authenticatedCipher
106+
}
107+
else -> {
108+
val auth = authenticator ?: throw SensitiveInfoException.EncryptionFailed(
109+
"Biometric authenticator unavailable",
110+
IllegalStateException("No authenticator configured")
111+
)
112+
// Android 7-9: authenticate first (no CryptoObject), then proceed
113+
auth.authenticate(
114+
prompt = resolvedPrompt,
115+
cipher = null,
116+
allowDeviceCredential = deviceCredentialAllowed
117+
)
118+
cipher.init(Cipher.ENCRYPT_MODE, key)
119+
cipher
120+
}
121+
}
122+
123+
val iv = workingCipher.iv
79124
?: throw SensitiveInfoException.EncryptionFailed(
80125
"Cipher did not generate IV",
81126
Exception("cipher.iv returned null")
82127
)
83-
84-
// Step 4: If authentication is required, show biometric prompt
85-
val readyCipher = if (resolution.requiresAuthentication) {
86-
val resolvedPrompt = prompt ?: AuthenticationPrompt(title = "Authenticate")
87-
val deviceCredentialAllowed =
88-
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
89-
authenticator?.authenticate(
90-
prompt = resolvedPrompt,
91-
cipher = cipher,
92-
allowDeviceCredential = deviceCredentialAllowed
93-
) ?: throw SensitiveInfoException.EncryptionFailed(
94-
"Biometric authenticator unavailable",
95-
IllegalStateException("No authenticator configured")
96-
)
97-
} else {
98-
cipher
99-
}
100-
101-
// Step 5: Encrypt the plaintext
102-
val ciphertext = readyCipher.doFinal(plaintext)
128+
val ciphertext = workingCipher.doFinal(plaintext)
103129

104130
EncryptionResult(ciphertext = ciphertext, iv = iv)
105131
} catch (e: SensitiveInfoException) {
@@ -132,11 +158,17 @@ internal class CryptoManager(
132158
* Decrypts ciphertext using stored IV and key from alias.
133159
*
134160
* **Workflow:**
135-
* 1. Get existing key from keystore
136-
* 2. Initialize AES-GCM cipher with stored IV
137-
* 3. If authentication required, show BiometricPrompt
138-
* 4. Decrypt data (automatically verifies GCM auth tag)
139-
* 5. Return plaintext
161+
* 1. Validate IV and fetch key from AndroidKeyStore
162+
* 2. Determine authentication strategy based on Android version
163+
* 3. If authentication required:
164+
* - **Android 10+ (API 29+)** → Authenticate *with* the decrypt cipher
165+
* - **Android 7-9 (API 24-28)** → Authenticate *before* initializing the cipher
166+
* 4. Initialize AES-GCM cipher with stored IV
167+
* 5. Decrypt (verifies GCM tag automatically)
168+
*
169+
* **Universal Authentication Model** mirrors encrypt(): caller always sees an
170+
* authentication prompt when required, while the keystore flow remains stable on
171+
* older devices.
140172
*
141173
* @param alias Unique key identifier (must match encryption)
142174
* @param ciphertext Encrypted data (includes 16-byte GCM auth tag)
@@ -174,30 +206,50 @@ internal class CryptoManager(
174206
)
175207
}
176208

177-
// Step 3: Initialize cipher with stored IV
209+
// Step 3: Prepare cipher and authentication strategy
178210
val cipher = Cipher.getInstance(TRANSFORMATION)
179211
val spec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)
180-
cipher.init(Cipher.DECRYPT_MODE, key, spec)
212+
val requiresAuth = resolution.requiresAuthentication
213+
val supportsKeystoreAuth = requiresAuth && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
214+
val deviceCredentialAllowed =
215+
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
216+
val resolvedPrompt = prompt ?: AuthenticationPrompt(title = "Authenticate")
181217

182-
// Step 4: If authentication is required, show biometric prompt
183-
val readyCipher = if (resolution.requiresAuthentication) {
184-
val resolvedPrompt = prompt ?: AuthenticationPrompt(title = "Authenticate")
185-
val deviceCredentialAllowed =
186-
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
187-
authenticator?.authenticate(
188-
prompt = resolvedPrompt,
189-
cipher = cipher,
190-
allowDeviceCredential = deviceCredentialAllowed
191-
) ?: throw SensitiveInfoException.DecryptionFailed(
192-
"Biometric authenticator unavailable",
193-
IllegalStateException("No authenticator configured")
194-
)
195-
} else {
196-
cipher
218+
val workingCipher = when {
219+
!requiresAuth -> {
220+
cipher.init(Cipher.DECRYPT_MODE, key, spec)
221+
cipher
222+
}
223+
supportsKeystoreAuth -> {
224+
val auth = authenticator ?: throw SensitiveInfoException.DecryptionFailed(
225+
"Biometric authenticator unavailable",
226+
IllegalStateException("No authenticator configured")
227+
)
228+
cipher.init(Cipher.DECRYPT_MODE, key, spec)
229+
val authenticatedCipher = auth.authenticate(
230+
prompt = resolvedPrompt,
231+
cipher = cipher,
232+
allowDeviceCredential = deviceCredentialAllowed
233+
) ?: cipher
234+
authenticatedCipher
235+
}
236+
else -> {
237+
val auth = authenticator ?: throw SensitiveInfoException.DecryptionFailed(
238+
"Biometric authenticator unavailable",
239+
IllegalStateException("No authenticator configured")
240+
)
241+
auth.authenticate(
242+
prompt = resolvedPrompt,
243+
cipher = null,
244+
allowDeviceCredential = deviceCredentialAllowed
245+
)
246+
cipher.init(Cipher.DECRYPT_MODE, key, spec)
247+
cipher
248+
}
197249
}
198250

199251
// Step 5: Decrypt and verify auth tag (fails if tag doesn't match)
200-
readyCipher.doFinal(ciphertext)
252+
workingCipher.doFinal(ciphertext)
201253
} catch (e: SensitiveInfoException) {
202254
throw e
203255
} catch (e: KeyPermanentlyInvalidatedException) {

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

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,14 @@ internal object KeyAuthenticationStrategy {
5858
applyApi29(builder, resolution)
5959
}
6060
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> {
61-
// API 28: Use deprecated timeout-based authentication
61+
// API 28: Manual pre-authentication (see applyApi28)
6262
applyApi28(builder, resolution)
6363
}
6464
else -> {
65-
// API 21-27: No biometric support in keystore
66-
// Fallback to plain key without authentication
67-
// The resolution should have been downgraded by AccessControlResolver
68-
// but we handle it gracefully here
69-
throw SensitiveInfoException.EncryptionFailed(
70-
"Biometric authentication not supported on API ${Build.VERSION.SDK_INT}",
71-
Exception("Minimum API for biometric keystore protection is 28")
72-
)
65+
// API 21-27: Hardware-enforced authentication unavailable.
66+
// We still present biometric/device credential prompts at the
67+
// application layer, but the key itself cannot enforce auth.
68+
builder.setUserAuthenticationRequired(false)
7369
}
7470
}
7571
}
@@ -117,26 +113,30 @@ internal object KeyAuthenticationStrategy {
117113

118114
/**
119115
* API 28 (Android 9): No setUserAuthenticationParameters.
120-
* Must use deprecated setUserAuthenticationValidityDurationSeconds.
121-
*
122-
* This is problematic because:
116+
* Can't use timeout-based authentication because:
123117
* 1. The timeout is rarely shown to users as a prompt
124-
* 2. It's deprecated in API 30+
125-
* 3. It doesn't integrate well with BiometricPrompt
118+
* 2. Cipher.init() must happen within the timeout window (impractical)
119+
* 3. It's deprecated in API 30+
120+
* 4. It doesn't integrate well with BiometricPrompt
121+
*
122+
* **Solution**: Don't require authentication at the KEY level.
123+
* Instead, rely on BiometricPrompt at the APPLICATION level (CryptoManager).
124+
* The workflow:
125+
* 1. Create key WITHOUT authentication requirement
126+
* 2. In CryptoManager, always show BiometricPrompt if authentication needed
127+
* 3. BiometricPrompt happens at app level, not key level
128+
* 4. This provides the same security: user must authenticate to access the secret
129+
* 5. But it works reliably on Android 9
126130
*
127-
* Solution: Use a 1-second timeout as a marker that auth WAS required.
128-
* The BiometricAuthenticator will handle the actual biometric prompt separately.
131+
* On Android 9, if the resolution requires authentication, we downgrade the key
132+
* to not require authentication, and rely on app-level BiometricPrompt instead.
129133
*/
130134
private fun applyApi28(
131135
builder: KeyGenParameterSpec.Builder,
132136
resolution: AccessResolution
133137
) {
134-
builder.setUserAuthenticationRequired(true)
135-
136-
// Can't use setUserAuthenticationParameters on API 28
137-
// Use timeout-based instead (deprecated, but necessary for API 28)
138-
@Suppress("DEPRECATION")
139-
builder.setUserAuthenticationValidityDurationSeconds(1)
138+
// Android 9 relies on application-level authentication; keystore auth is disabled.
139+
builder.setUserAuthenticationRequired(false)
140140
}
141141

142142
/**

0 commit comments

Comments
 (0)