Skip to content

Commit bd82c14

Browse files
committed
Improve Android 13+ keystore authentication handling
Refactors CryptoManager to robustly handle Android 13+ keystore authentication edge cases, including device credential incompatibility and key recreation logic. Adds detailed exception tracing, separates keystore/app-gated authentication flows, and updates KeyAuthenticationStrategy to map authenticators and timeouts correctly for modern Android versions. These changes improve reliability and error reporting for biometric and device credential authentication across Android versions.
1 parent ec935c8 commit bd82c14

File tree

2 files changed

+323
-39
lines changed

2 files changed

+323
-39
lines changed

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

Lines changed: 265 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
55
import android.security.keystore.KeyPermanentlyInvalidatedException
66
import android.security.keystore.KeyProperties
77
import android.security.keystore.StrongBoxUnavailableException
8+
import android.security.keystore.UserNotAuthenticatedException
89
import androidx.biometric.BiometricManager.Authenticators
910
import com.sensitiveinfo.internal.auth.BiometricAuthenticator
1011
import com.sensitiveinfo.internal.auth.AuthenticationPrompt
@@ -19,6 +20,7 @@ import javax.crypto.spec.GCMParameterSpec
1920
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
2021
private const val TRANSFORMATION = "AES/GCM/NoPadding"
2122
private const val GCM_TAG_LENGTH_BITS = 128
23+
private const val TAG = "CryptoManager"
2224

2325
/**
2426
* Manages AES-256-GCM encryption/decryption with AndroidKeyStore.
@@ -82,8 +84,6 @@ internal class CryptoManager(
8284
val cipher = Cipher.getInstance(TRANSFORMATION)
8385
val requiresAuth = resolution.requiresAuthentication
8486
val supportsKeystoreAuth = requiresAuth && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
85-
val deviceCredentialAllowed =
86-
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
8787
val resolvedPrompt = prompt ?: AuthenticationPrompt(title = "Authenticate")
8888

8989
val workingCipher = when {
@@ -92,24 +92,23 @@ internal class CryptoManager(
9292
cipher
9393
}
9494
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,
95+
// Android 10+: Authenticate WITH cipher (keystore-gated auth)
96+
authenticateAndEncrypt(
10297
cipher = cipher,
103-
allowDeviceCredential = deviceCredentialAllowed
104-
) ?: cipher
105-
authenticatedCipher
98+
key = key,
99+
prompt = resolvedPrompt,
100+
mode = Cipher.ENCRYPT_MODE,
101+
resolution = resolution
102+
)
106103
}
107104
else -> {
105+
// Android 7-9: Authenticate BEFORE cipher init (app-gated auth)
108106
val auth = authenticator ?: throw SensitiveInfoException.EncryptionFailed(
109107
"Biometric authenticator unavailable",
110108
IllegalStateException("No authenticator configured")
111109
)
112-
// Android 7-9: authenticate first (no CryptoObject), then proceed
110+
val deviceCredentialAllowed =
111+
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
113112
auth.authenticate(
114113
prompt = resolvedPrompt,
115114
cipher = null,
@@ -134,9 +133,43 @@ internal class CryptoManager(
134133
// Key was invalidated (e.g., biometric enrollment changed)
135134
deleteKey(alias)
136135
throw SensitiveInfoException.KeyInvalidated(alias)
136+
} catch (e: UserNotAuthenticatedException) {
137+
// Android 13+: User must authenticate before key can be used
138+
// This often happens if cipher was initialized but auth timed out
139+
throw SensitiveInfoException.EncryptionFailed(
140+
"Authentication required but not completed: Device credential or biometric needed",
141+
e
142+
)
137143
} catch (e: Exception) {
144+
// Check if this is the "Key user not authenticated" error from old key format
145+
// The error can appear at various levels in the cause chain
146+
val isBadKeyError = e.message?.contains("Key user not authenticated") == true ||
147+
checkCauseChainForKeyError(e)
148+
149+
if (isBadKeyError && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
150+
// Android 13+: Old key format (with AUTH_DEVICE_CREDENTIAL) is incompatible
151+
// Delete it so a new key gets created with correct format
152+
153+
deleteKey(alias)
154+
155+
// Retry with new key
156+
return try {
157+
encrypt(alias, plaintext, resolution, prompt)
158+
} catch (retryError: Exception) {
159+
160+
throw SensitiveInfoException.EncryptionFailed(
161+
"Encryption failed after key recreation: ${retryError.message}",
162+
retryError
163+
)
164+
}
165+
}
166+
167+
168+
val exceptionType = e::class.simpleName ?: "Unknown"
169+
val causeChain = buildCauseChain(e)
170+
138171
throw SensitiveInfoException.EncryptionFailed(
139-
"Encryption failed: ${e.message}",
172+
"Encryption failed: $exceptionType - ${e.message ?: "Unknown error"}\n$causeChain",
140173
e
141174
)
142175
}
@@ -211,8 +244,6 @@ internal class CryptoManager(
211244
val spec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)
212245
val requiresAuth = resolution.requiresAuthentication
213246
val supportsKeystoreAuth = requiresAuth && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
214-
val deviceCredentialAllowed =
215-
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
216247
val resolvedPrompt = prompt ?: AuthenticationPrompt(title = "Authenticate")
217248

218249
val workingCipher = when {
@@ -221,23 +252,24 @@ internal class CryptoManager(
221252
cipher
222253
}
223254
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,
255+
// Android 10+: Authenticate WITH cipher (keystore-gated auth)
256+
authenticateAndDecrypt(
231257
cipher = cipher,
232-
allowDeviceCredential = deviceCredentialAllowed
233-
) ?: cipher
234-
authenticatedCipher
258+
key = key,
259+
spec = spec,
260+
prompt = resolvedPrompt,
261+
mode = Cipher.DECRYPT_MODE,
262+
resolution = resolution
263+
)
235264
}
236265
else -> {
266+
// Android 7-9: Authenticate BEFORE cipher init (app-gated auth)
237267
val auth = authenticator ?: throw SensitiveInfoException.DecryptionFailed(
238268
"Biometric authenticator unavailable",
239269
IllegalStateException("No authenticator configured")
240270
)
271+
val deviceCredentialAllowed =
272+
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0
241273
auth.authenticate(
242274
prompt = resolvedPrompt,
243275
cipher = null,
@@ -256,12 +288,39 @@ internal class CryptoManager(
256288
// Key was invalidated (e.g., biometric enrollment changed)
257289
deleteKey(alias)
258290
throw SensitiveInfoException.KeyInvalidated(alias)
291+
} catch (e: UserNotAuthenticatedException) {
292+
// Android 13+: User must authenticate before key can be used
293+
throw SensitiveInfoException.DecryptionFailed(
294+
"Authentication required but not completed: Device credential or biometric needed",
295+
e
296+
)
259297
} catch (e: UnrecoverableKeyException) {
260298
throw SensitiveInfoException.DecryptionFailed(
261299
"Key is unrecoverable (wrong password or key corrupted)",
262300
e
263301
)
264302
} catch (e: Exception) {
303+
// Check if this is the "Key user not authenticated" error from old key format
304+
val isBadKeyError = e.message?.contains("Key user not authenticated") == true ||
305+
(e.cause?.message?.contains("No operation auth token received") == true)
306+
307+
if (isBadKeyError && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
308+
// Android 13+: Old key format (with AUTH_DEVICE_CREDENTIAL) is incompatible
309+
// Delete it so a new key gets created with correct format
310+
311+
deleteKey(alias)
312+
313+
// Cannot retry decrypt - we don't have the plaintext anymore
314+
throw SensitiveInfoException.DecryptionFailed(
315+
"Key was incompatible with Android 13+ authentication model and has been deleted. " +
316+
"Please re-encrypt data with new key format.",
317+
e
318+
)
319+
}
320+
321+
val exceptionType = e::class.simpleName ?: "Unknown"
322+
val causeChain = buildCauseChain(e)
323+
265324
when {
266325
e.message?.contains("Tag verification failed") == true ->
267326
throw SensitiveInfoException.DecryptionFailed(
@@ -275,7 +334,7 @@ internal class CryptoManager(
275334
)
276335
else ->
277336
throw SensitiveInfoException.DecryptionFailed(
278-
"Decryption failed: ${e.message}",
337+
"Decryption failed: $exceptionType - ${e.message ?: "Unknown error"}\n$causeChain",
279338
e
280339
)
281340
}
@@ -336,6 +395,136 @@ internal class CryptoManager(
336395
// PRIVATE HELPERS
337396
// ============================================================================
338397

398+
/**
399+
* Authenticates and encrypts using keystore-gated authentication (Android 10+).
400+
*
401+
* **Why separate method**:
402+
* - DRY: Avoids repeating cipher/a
403+
* - SRP: Single responsibility—handle keystore auth ceremony
404+
* - Testability: Can mock/test authentication flow independently
405+
*
406+
* **Workflow**:
407+
* 1. Initialize cipher (puts it in a pre-authenticated state)
408+
* 2. Authenticate WITH the cipher as CryptoObject (keystore validates the prompt)
409+
* 3. Return the authenticated cipher ready for encryption
410+
*
411+
* **Key insight**: The cipher must be initialized BEFORE authentication on Android 10+,
412+
* so the keystore can wrap it with authentication. This is the opposite of Android 9.
413+
*/
414+
private suspend fun authenticateAndEncrypt(
415+
cipher: Cipher,
416+
key: SecretKey,
417+
prompt: AuthenticationPrompt,
418+
mode: Int,
419+
resolution: AccessResolution
420+
): Cipher {
421+
val auth = authenticator ?: throw SensitiveInfoException.EncryptionFailed(
422+
"Biometric authenticator unavailable",
423+
IllegalStateException("No authenticator configured")
424+
)
425+
426+
return try {
427+
// Initialize cipher for the keystore-gated auth flow
428+
cipher.init(mode, key)
429+
430+
// On Android 13+, device credential is excluded from the key itself,
431+
// so we should NOT allow it in BiometricPrompt (only biometric).
432+
// On Android 10-12, device credential is in the key, so we can allow it.
433+
val deviceCredentialAllowed =
434+
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0 &&
435+
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
436+
437+
// Pass cipher as CryptoObject so keystore can generate auth token
438+
val authenticatedCipher = auth.authenticate(
439+
prompt = prompt,
440+
cipher = cipher,
441+
allowDeviceCredential = deviceCredentialAllowed
442+
)
443+
authenticatedCipher ?: cipher
444+
} catch (e: Exception) {
445+
throw e
446+
}
447+
}
448+
449+
/**
450+
* Authenticates and decrypts using keystore-gated authentication (Android 10+).
451+
*
452+
* **Why separate method**:
453+
* - DRY: Avoids repeating cipher/a
454+
* - SRP: Single responsibility—handle keystore auth ceremony
455+
* - Testability: Can mock/test authentication flow independently
456+
*
457+
* **Workflow**:
458+
* 1. Initialize cipher with IV spec (puts it in a pre-authenticated state)
459+
* 2. Authenticate WITH the cipher as CryptoObject (keystore validates the prompt)
460+
* 3. Return the authenticated cipher ready for decryption
461+
*
462+
* **Key insight**: Same as encrypt() but with IV spec for GCM mode.
463+
*/
464+
private suspend fun authenticateAndDecrypt(
465+
cipher: Cipher,
466+
key: SecretKey,
467+
spec: GCMParameterSpec,
468+
prompt: AuthenticationPrompt,
469+
mode: Int,
470+
resolution: AccessResolution
471+
): Cipher {
472+
val auth = authenticator ?: throw SensitiveInfoException.DecryptionFailed(
473+
"Biometric authenticator unavailable",
474+
IllegalStateException("No authenticator configured")
475+
)
476+
477+
return try {
478+
// Initialize cipher with GCM spec for the keystore-gated auth flow
479+
cipher.init(mode, key, spec)
480+
481+
// On Android 13+, device credential is excluded from the key itself,
482+
// so we should NOT allow it in BiometricPrompt (only biometric).
483+
// On Android 10-12, device credential is in the key, so we can allow it.
484+
val deviceCredentialAllowed =
485+
(resolution.allowedAuthenticators and Authenticators.DEVICE_CREDENTIAL) != 0 &&
486+
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
487+
488+
// Pass cipher as CryptoObject so keystore can generate auth token
489+
val authenticatedCipher = auth.authenticate(
490+
prompt = prompt,
491+
cipher = cipher,
492+
allowDeviceCredential = deviceCredentialAllowed
493+
)
494+
authenticatedCipher ?: cipher
495+
} catch (e: Exception) {
496+
throw e
497+
}
498+
}
499+
500+
/**
501+
* Authenticates using app-gated authentication (Android 7-9).
502+
*
503+
* **Why separate method**:
504+
* - DRY: Avoids repeating a
505+
* - SRP: Single responsibility—handle app-level auth ceremony
506+
* - Clarity: Name signals "we auth BEFORE crypto, not with crypto"
507+
*
508+
* **Workflow**:
509+
* 1. Show BiometricPrompt WITHOUT cipher (app manages the UI)
510+
* 2. User authenticates via biometric or device credential
511+
* 3. Return (cipher init happens after this returns)
512+
*/
513+
private suspend fun authenticateAppGated(
514+
prompt: AuthenticationPrompt,
515+
allowDeviceCredential: Boolean
516+
) {
517+
val auth = authenticator ?: throw SensitiveInfoException.EncryptionFailed(
518+
"Biometric authenticator unavailable",
519+
IllegalStateException("No authenticator configured")
520+
)
521+
auth.authenticate(
522+
prompt = prompt,
523+
cipher = null,
524+
allowDeviceCredential = allowDeviceCredential
525+
)
526+
}
527+
339528
/**
340529
* Gets existing key or creates new one if doesn't exist.
341530
*
@@ -463,4 +652,53 @@ internal class CryptoManager(
463652
)
464653
}
465654
}
655+
656+
/**
657+
* Helper function to build a detailed exception cause chain for debugging.
658+
*
659+
* @param throwable The exception to analyze
660+
* @return A formatted string showing the exception class names and messages in chain
661+
*/
662+
private fun buildCauseChain(throwable: Throwable): String {
663+
val chain = mutableListOf<String>()
664+
var current: Throwable? = throwable
665+
var depth = 0
666+
val maxDepth = 5 // Limit depth to avoid overly long strings
667+
668+
while (current != null && depth < maxDepth) {
669+
val className = current::class.simpleName ?: "Unknown"
670+
val message = current.message?.take(100) ?: "(no message)"
671+
chain.add(" [$depth] $className: $message")
672+
current = current.cause
673+
depth++
674+
}
675+
676+
return if (chain.isEmpty()) "(empty cause chain)" else chain.joinToString("\n")
677+
}
678+
679+
/**
680+
* Helper function to detect "Key user not authenticated" error in exception cause chain.
681+
*
682+
* On Android 13+, this error can be wrapped in multiple layers:
683+
* IllegalBlockSizeException → KeyStoreException → (underlying error)
684+
*
685+
* @param throwable The exception to check
686+
* @return true if "Key user not authenticated" is found anywhere in the cause chain
687+
*/
688+
private fun checkCauseChainForKeyError(throwable: Throwable): Boolean {
689+
var current: Throwable? = throwable
690+
var depth = 0
691+
val maxDepth = 10 // Check up to 10 levels deep
692+
693+
while (current != null && depth < maxDepth) {
694+
if (current.message?.contains("Key user not authenticated") == true ||
695+
current.message?.contains("No operation auth token received") == true) {
696+
return true
697+
}
698+
current = current.cause
699+
depth++
700+
}
701+
702+
return false
703+
}
466704
}

0 commit comments

Comments
 (0)