@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
55import android.security.keystore.KeyPermanentlyInvalidatedException
66import android.security.keystore.KeyProperties
77import android.security.keystore.StrongBoxUnavailableException
8+ import android.security.keystore.UserNotAuthenticatedException
89import androidx.biometric.BiometricManager.Authenticators
910import com.sensitiveinfo.internal.auth.BiometricAuthenticator
1011import com.sensitiveinfo.internal.auth.AuthenticationPrompt
@@ -19,6 +20,7 @@ import javax.crypto.spec.GCMParameterSpec
1920private const val ANDROID_KEY_STORE = " AndroidKeyStore"
2021private const val TRANSFORMATION = " AES/GCM/NoPadding"
2122private 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