@@ -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 ) {
0 commit comments