refactor(security): de-duplicate AES-GCM Keystore plumbing and unify authenticator policy

Extract the AES-256-GCM Android Keystore plumbing that `KeystoreCrypto` and
`DatabaseKeyCipher` copy-pasted (~60 lines) into a shared alias-parameterized
base, `AesGcmKeystoreCipher`: the encrypt/decrypt bodies, existing-key lookup /
get-or-create under a lock, key deletion, and the 5 identical GCM constants now
live in ONE place. Each cipher keeps only its delta — the `KeyGenParameterSpec`
(via `keySpecBuilder()`) and, for the auth-bound key, the invalidation handling.

Preserve — deliberately — the two ciphers' different missing-key-on-decrypt
behavior via a `generateKeyOnDecrypt` policy parameter, documented on the base:
- master key (`KeystoreCrypto`, true): auto-generates on a missing alias, correct
  for a first-run key with nothing sealed yet.
- auth-bound cache key (`DatabaseKeyCipher`, false): fails fast, because a missing
  auth-bound key means it was INVALIDATED and silently regenerating it would
  re-arm the lock against a cache that can no longer be decrypted.
Also map the opaque `AEADBadTagException` (thrown when the master path generates a
fresh key then can't decrypt old data) to a clear `GeneralSecurityException`,
while leaving `KeyPermanentlyInvalidatedException` to propagate unwrapped.

Unify the accepted-authenticator policy behind one source of truth,
`AuthenticatorPolicy.ACCEPTED`, mapped into each API's vocabulary
(`AppLockManager.AUTHENTICATORS` for BiometricManager / BiometricPrompt,
`DatabaseKeyCipher.keySpec` for KeyProperties / KeyGenParameterSpec) so the two
can no longer drift — a drift that yields a prompt that succeeds but a key that
throws `UserNotAuthenticatedException` at use.

Add JVM tests for the shared base (both `generateKeyOnDecrypt` modes + the AES-GCM
error mapping) and for the authenticator mapping. #100's seal-exchange and
policy-table safety net stays green.

Closes #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:21:09 -05:00
co-authored by Claude Fable 5
parent 73d6a44a3f
commit d424abc6d3
7 changed files with 407 additions and 123 deletions
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Shared AES-256-GCM plumbing backed by a non-exportable key in the Android Keystore, parameterized
* by key [alias] and a missing-key-on-decrypt policy ([generateKeyOnDecrypt]).
*
* Encapsulates everything the two Keystore users have in common — the GCM transform and its
* constants, the alias-scoped key lookup/creation guarded by a lock, `Base64(iv || ciphertext)`
* framing, and key deletion — so a change to the crypto (StrongBox opt-in, IV handling, error
* mapping) is made in ONE place instead of being copy-pasted and drifting. Subclasses supply only
* their delta: the [keySpec] that mints the key (extend [keySpecBuilder] for the common
* AES-256-GCM base) and, via [generateKeyOnDecrypt], how a decrypt behaves when the alias is absent.
*
* That missing-key policy is **deliberately different** between the two users and MUST stay
* different:
* - The non-auth master key ([KeystoreCrypto], `generateKeyOnDecrypt = true`) silently generates a
* key on a missing alias — correct for a first-run master key that has nothing sealed yet.
* - The auth-bound cache key ([DatabaseKeyCipher], `generateKeyOnDecrypt = false`) fails fast,
* because a MISSING auth-bound key means it was INVALIDATED (biometric re-enrollment or lock
* removal). Silently regenerating it would defeat the security model — quietly re-arming a lock
* against a cache that can no longer be decrypted — so the absence must surface, not self-heal.
*
* The key operations touch the Android Keystore, so the two concrete ciphers are exercised on-device;
* the alias/policy wiring above is unit-tested against this base with fake keys (see the test seams
* [existingKey], [getOrCreateKey], and [decryptWithKey]).
*/
abstract class AesGcmKeystoreCipher(private val alias: String, private val generateKeyOnDecrypt: Boolean) {
private val keyLock = Any()
/** Returns `Base64(iv || ciphertext)`, generating the key under [alias] on first use. */
open fun encrypt(plaintext: String): String = doEncrypt(getOrCreateKey(), plaintext)
/**
* Decrypts a blob produced by [encrypt]. Missing-key handling follows [generateKeyOnDecrypt]
* (see the class KDoc). An AES-GCM tag mismatch — the ciphertext no longer matches the key, e.g.
* the alias was cleared and regenerated underneath a still-persisted blob — is surfaced as a
* clear [GeneralSecurityException] instead of an opaque [AEADBadTagException]. A key-invalidation
* failure ([android.security.keystore.KeyPermanentlyInvalidatedException]) is thrown from
* `Cipher.init` and propagates unwrapped, so callers can still classify it.
*/
fun decrypt(encoded: String): String {
val key = decryptionKey()
return try {
decryptWithKey(key, encoded)
} catch (e: AEADBadTagException) {
throw GeneralSecurityException(
"AES-GCM authentication failed for Keystore alias '$alias': the ciphertext no longer " +
"matches the current key (the key was cleared and regenerated, or the data is corrupt)",
e,
)
}
}
/** Resolves the key a decrypt should use, applying the [generateKeyOnDecrypt] policy. */
private fun decryptionKey(): SecretKey =
if (generateKeyOnDecrypt) getOrCreateKey() else existingKey() ?: onMissingDecryptionKey()
/**
* Invoked when a decrypt finds no key and the policy forbids minting one. The default fails with
* a generic message; auth-bound subclasses override it to explain that the absence means the key
* was invalidated.
*/
protected open fun onMissingDecryptionKey(): Nothing = error("Keystore key for alias '$alias' is missing")
private fun doEncrypt(key: SecretKey, plaintext: String): String {
val cipher = initEncryptCipher(key)
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP)
}
/** Test seam separating the Keystore-backed cipher call from the decrypt policy/error mapping. */
protected open fun decryptWithKey(key: SecretKey, encoded: String): String {
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
val iv = bytes.copyOfRange(0, IV_LENGTH)
val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
/**
* Initializes an encrypt-mode [Cipher] with [key]. Shared by [doEncrypt] and reused by auth-bound
* subclasses to probe whether a key is still usable (a bare `init` throws if it was invalidated).
*/
protected fun initEncryptCipher(key: SecretKey): Cipher =
Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key) }
/** True when a key exists under [alias]. */
protected fun keyExists(): Boolean = existingKey() != null
/** Deletes the key so a fresh one is generated on the next [encrypt]. */
protected fun deleteKeyEntry(): Unit = synchronized(keyLock) {
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(alias)
}
protected open fun existingKey(): SecretKey? = synchronized(keyLock) {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
}
// Synchronized so two concurrent first-run encrypts can't both generate a key under the same
// alias — the second would overwrite the first, leaving the first secret undecryptable.
protected open fun getOrCreateKey(): SecretKey = synchronized(keyLock) {
existingKey()?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(keySpec())
generator.generateKey()
}
/** The alias-bound [KeyGenParameterSpec] for this key; subclasses extend [keySpecBuilder]. */
protected abstract fun keySpec(): KeyGenParameterSpec
/** The common AES-256-GCM builder (encrypt + decrypt, GCM, no padding, 256-bit) to extend. */
protected fun keySpecBuilder(): KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE_BITS)
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_LENGTH = 12
const val TAG_BITS = 128
const val AES_KEY_SIZE_BITS = 256
}
}
@@ -3,8 +3,6 @@ package org.libremail.data.security
import android.app.KeyguardManager
import android.content.Context
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,8 +17,13 @@ interface AppLockManager {
fun isDeviceSecure(): Boolean
companion object {
/** Accept a strong biometric OR the device credential (PIN/pattern/password) as fallback. */
const val AUTHENTICATORS: Int = BIOMETRIC_STRONG or DEVICE_CREDENTIAL
/**
* `BiometricPrompt` authenticators (a strong biometric OR the device credential). Derived
* from the single [AuthenticatorPolicy] source of truth so it can never drift from the
* auth-bound Keystore key's authenticators in [DatabaseKeyCipher] — a drift that would let a
* prompt succeed against an authenticator the key rejects with `UserNotAuthenticatedException`.
*/
val AUTHENTICATORS: Int = AuthenticatorPolicy.biometricPromptAuthenticators
}
}
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager
/** A user-presence proof the app accepts to unlock the app lock and authorize the auth-bound key. */
enum class AppAuthenticator { STRONG_BIOMETRIC, DEVICE_CREDENTIAL }
/**
* THE single source of truth for which authenticators gate the app lock. The same [ACCEPTED] set is
* mapped into each Android API's own flag vocabulary — androidx [BiometricManager] (for
* `BiometricPrompt.setAllowedAuthenticators`, via [AppLockManager.AUTHENTICATORS]) and platform
* [KeyProperties] (for `KeyGenParameterSpec.setUserAuthenticationParameters`, via
* [DatabaseKeyCipher]) — because the two APIs use *different* bit constants for the same concept
* (`BIOMETRIC_STRONG` is `0xF` here, `AUTH_BIOMETRIC_STRONG` is `1` there).
*
* Deriving both flag sets from one [ACCEPTED] set — with an exhaustive `when` that the compiler forces
* to cover every [AppAuthenticator] — makes it impossible to loosen or tighten one without the other.
* That drift is otherwise invisible until a device hits it: the `BiometricPrompt` would accept an
* authenticator the key does not, so the prompt succeeds but the key then throws
* `UserNotAuthenticatedException` at use.
*/
object AuthenticatorPolicy {
/** Accept a strong biometric OR the device credential (PIN / pattern / password) as fallback. */
val ACCEPTED: Set<AppAuthenticator> =
setOf(AppAuthenticator.STRONG_BIOMETRIC, AppAuthenticator.DEVICE_CREDENTIAL)
/** [ACCEPTED] as androidx `BiometricManager.Authenticators` flags for a `BiometricPrompt`. */
val biometricPromptAuthenticators: Int = ACCEPTED.toFlags {
when (it) {
AppAuthenticator.STRONG_BIOMETRIC -> BiometricManager.Authenticators.BIOMETRIC_STRONG
AppAuthenticator.DEVICE_CREDENTIAL -> BiometricManager.Authenticators.DEVICE_CREDENTIAL
}
}
/** [ACCEPTED] as platform `KeyProperties.AUTH_*` flags for a `KeyGenParameterSpec`. */
val keyGenAuthenticators: Int = ACCEPTED.toFlags {
when (it) {
AppAuthenticator.STRONG_BIOMETRIC -> KeyProperties.AUTH_BIOMETRIC_STRONG
AppAuthenticator.DEVICE_CREDENTIAL -> KeyProperties.AUTH_DEVICE_CREDENTIAL
}
}
private inline fun Set<AppAuthenticator>.toFlags(flagOf: (AppAuthenticator) -> Int): Int =
fold(0) { acc, authenticator -> acc or flagOf(authenticator) }
}
@@ -4,15 +4,8 @@ package org.libremail.data.security
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.security.keystore.UserNotAuthenticatedException
import android.util.Base64
import android.util.Log
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,47 +23,29 @@ import javax.inject.Singleton
* lock — permanently invalidates the key; [decrypt] then throws [KeyPermanentlyInvalidatedException],
* which the caller treats as "cache unrecoverable -> clear + re-sync".
*
* Reuses the shared [AesGcmKeystoreCipher] plumbing; its delta is the auth-bound [keySpec] and the
* invalidation handling below. It is created with `generateKeyOnDecrypt = false` on purpose: a
* missing auth-bound key means it was INVALIDATED, so [decrypt] fails fast (via [onMissingDecryptionKey])
* rather than silently minting a new key and re-arming the lock against a cache it can never decrypt.
*
* DEVICE-ONLY: auth-bound Keystore keys and BiometricPrompt cannot be exercised in JVM unit tests;
* this class is covered by on-device instrumentation / manual validation only.
*/
@Singleton
class DatabaseKeyCipher @Inject constructor() {
private val keyLock = Any()
class DatabaseKeyCipher @Inject constructor() :
AesGcmKeystoreCipher(alias = KEY_ALIAS, generateKeyOnDecrypt = false) {
/**
* Returns Base64(iv || ciphertext). Requires a valid auth window (call right after unlock).
* Self-heals a stale, permanently-invalidated key by replacing it and retrying once, so sealing a
* fresh passphrase after a re-enrollment doesn't fail.
*/
fun encrypt(plaintext: String): String = try {
doEncrypt(getOrCreateKey(), plaintext)
override fun encrypt(plaintext: String): String = try {
super.encrypt(plaintext)
} catch (e: KeyPermanentlyInvalidatedException) {
Log.d(TAG, "replacing invalidated auth-bound key before sealing", e)
deleteKey()
doEncrypt(getOrCreateKey(), plaintext)
}
private fun doEncrypt(key: SecretKey, plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP)
}
/**
* Decrypts a blob produced by [encrypt]. Requires a valid auth window. Throws
* [KeyPermanentlyInvalidatedException] if the key was invalidated by re-enrollment / lock removal.
*/
fun decrypt(encoded: String): String {
val key = existingKey() ?: error("auth-bound database key is missing")
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
val iv = bytes.copyOfRange(0, IV_LENGTH)
val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
super.encrypt(plaintext)
}
/**
@@ -82,7 +57,7 @@ class DatabaseKeyCipher @Inject constructor() {
fun isInvalidated(): Boolean {
val key = existingKey() ?: return false
return try {
Cipher.getInstance(TRANSFORMATION).init(Cipher.ENCRYPT_MODE, key)
initEncryptCipher(key)
false
} catch (e: KeyPermanentlyInvalidatedException) {
Log.d(TAG, "auth-bound database key invalidated", e)
@@ -99,39 +74,22 @@ class DatabaseKeyCipher @Inject constructor() {
}
}
fun hasKey(): Boolean = existingKey() != null
fun hasKey(): Boolean = keyExists()
/** Deletes the auth-bound key so a fresh one is generated on the next [encrypt]. */
fun deleteKey(): Unit = synchronized(keyLock) {
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(KEY_ALIAS)
}
fun deleteKey(): Unit = deleteKeyEntry()
private fun existingKey(): SecretKey? = synchronized(keyLock) {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
}
/** A missing auth-bound key means it was invalidated; surface that instead of regenerating. */
override fun onMissingDecryptionKey(): Nothing = error("auth-bound database key is missing")
private fun getOrCreateKey(): SecretKey = synchronized(keyLock) {
existingKey()?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(buildSpec())
generator.generateKey()
}
private fun buildSpec(): KeyGenParameterSpec {
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE_BITS)
override fun keySpec(): KeyGenParameterSpec {
val builder = keySpecBuilder()
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(
AUTH_VALIDITY_SECONDS,
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL,
AuthenticatorPolicy.keyGenAuthenticators,
)
} else {
@Suppress("DEPRECATION")
@@ -141,12 +99,7 @@ class DatabaseKeyCipher @Inject constructor() {
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "libremail.dbkey.auth"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_LENGTH = 12
const val TAG_BITS = 128
const val AES_KEY_SIZE_BITS = 256
const val AUTH_VALIDITY_SECONDS = 15
const val TAG = "LibreMailDbKeyAuth"
}
@@ -2,69 +2,25 @@
package org.libremail.data.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* AES-256-GCM encryption backed by a non-exportable key in the Android Keystore. Secrets
* (OAuth tokens, IMAP passwords) are encrypted at rest so they never touch disk in plaintext.
* AES-256-GCM encryption backed by a non-exportable key in the Android Keystore. Secrets (OAuth
* tokens, IMAP passwords) are encrypted at rest so they never touch disk in plaintext.
*
* The non-auth-bound **master** key: usable in the background without a user-presence prompt, so
* credential access keeps working while the app is locked. As the master key it is minted lazily on a
* missing-alias decrypt (`generateKeyOnDecrypt = true`) — correct for a first run that has nothing
* sealed yet. Contrast the auth-bound [DatabaseKeyCipher], whose absent key means invalidation and so
* fails fast; the shared [AesGcmKeystoreCipher] documents why the two must differ.
*/
@Singleton
class KeystoreCrypto @Inject constructor() {
class KeystoreCrypto @Inject constructor() : AesGcmKeystoreCipher(alias = KEY_ALIAS, generateKeyOnDecrypt = true) {
private val keyLock = Any()
// Synchronized so two concurrent first-run encrypts can't both generate a key under the same
// alias — the second would overwrite the first, leaving the first secret undecryptable.
private fun secretKey(): SecretKey = synchronized(keyLock) {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE_BITS)
.build(),
)
generator.generateKey()
}
/** Returns Base64(iv || ciphertext). */
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP)
}
fun decrypt(encoded: String): String {
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
val iv = bytes.copyOfRange(0, IV_LENGTH)
val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
override fun keySpec(): KeyGenParameterSpec = keySpecBuilder().build()
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val KEY_ALIAS = "libremail.master.key"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_LENGTH = 12
const val TAG_BITS = 128
const val AES_KEY_SIZE_BITS = 256
}
}
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.security.keystore.KeyGenParameterSpec
import org.junit.Test
import java.security.GeneralSecurityException
import javax.crypto.AEADBadTagException
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* JVM coverage for the shared [AesGcmKeystoreCipher] wiring — specifically the deliberately different
* missing-key-on-decrypt policy the two production ciphers depend on, plus the AES-GCM error mapping.
* The Keystore-backed operations (real key generation and the GCM cipher) are device-only, so they
* are replaced here through the [existingKey], [getOrCreateKey], and [decryptWithKey] seams; what is
* pinned is the base's control flow: which key a decrypt resolves under each `generateKeyOnDecrypt`
* mode, and how a tag mismatch is surfaced.
*/
class AesGcmKeystoreCipherTest {
@Test
fun `generateKeyOnDecrypt true mints a key for a missing alias (master-key behavior)`() {
val cipher = FakeCipher(generateKeyOnDecrypt = true, storedKey = null)
assertEquals("plain:blob", cipher.decrypt("blob"))
assertEquals(1, cipher.generatedKeys, "a missing master alias is generated on decrypt")
}
@Test
fun `generateKeyOnDecrypt true reuses an existing key without regenerating`() {
val cipher = FakeCipher(generateKeyOnDecrypt = true, storedKey = newAesKey())
assertEquals("plain:blob", cipher.decrypt("blob"))
assertEquals(0, cipher.generatedKeys)
}
@Test
fun `generateKeyOnDecrypt false fails fast for a missing alias (auth-bound behavior)`() {
val cipher = FakeCipher(generateKeyOnDecrypt = false, storedKey = null)
val error = assertFailsWith<IllegalStateException> { cipher.decrypt("blob") }
assertTrue(error.message!!.contains("test.alias"))
assertEquals(0, cipher.generatedKeys, "an absent auth-bound key must NOT be silently regenerated")
assertEquals(0, cipher.decryptCalls, "decrypt short-circuits before touching the cipher")
}
@Test
fun `generateKeyOnDecrypt false decrypts with the existing key`() {
val cipher = FakeCipher(generateKeyOnDecrypt = false, storedKey = newAesKey())
assertEquals("plain:blob", cipher.decrypt("blob"))
assertEquals(0, cipher.generatedKeys)
}
@Test
fun `an AES-GCM tag mismatch is remapped to a clear GeneralSecurityException`() {
val badTag = AEADBadTagException("tag mismatch")
val cipher = FakeCipher(
generateKeyOnDecrypt = true,
storedKey = newAesKey(),
onDecrypt = { _, _ -> throw badTag },
)
val error = assertFailsWith<GeneralSecurityException> { cipher.decrypt("blob") }
assertSame(badTag, error.cause)
assertTrue(error.message!!.contains("test.alias"))
}
@Test
fun `a non-AEAD failure propagates unwrapped so key invalidation still surfaces`() {
// Only AEADBadTagException is remapped; every other cipher failure — including the
// KeyPermanentlyInvalidatedException a real init throws on an invalidated key — must propagate
// unchanged so callers can classify it.
val boom = IllegalArgumentException("boom")
val cipher = FakeCipher(
generateKeyOnDecrypt = false,
storedKey = newAesKey(),
onDecrypt = { _, _ -> throw boom },
)
assertSame(boom, assertFailsWith<IllegalArgumentException> { cipher.decrypt("blob") })
}
/**
* A JVM-only [AesGcmKeystoreCipher] whose Keystore seams are replaced by in-memory fakes so the
* base's key-resolution policy and error mapping run without a device. [storedKey] models the key
* present under the alias (null = absent); [onDecrypt] models the GCM cipher operation.
*/
private class FakeCipher(
generateKeyOnDecrypt: Boolean,
private val storedKey: SecretKey?,
private val onDecrypt: (SecretKey, String) -> String = { _, encoded -> "plain:$encoded" },
) : AesGcmKeystoreCipher(alias = "test.alias", generateKeyOnDecrypt = generateKeyOnDecrypt) {
var generatedKeys = 0
private set
var decryptCalls = 0
private set
override fun existingKey(): SecretKey? = storedKey
override fun getOrCreateKey(): SecretKey = existingKey() ?: newAesKey().also { generatedKeys++ }
override fun decryptWithKey(key: SecretKey, encoded: String): String {
decryptCalls++
return onDecrypt(key, encoded)
}
override fun keySpec(): KeyGenParameterSpec = error("keySpec is not exercised in the JVM base test")
}
private companion object {
const val KEY_BYTES = 32
fun newAesKey(): SecretKey = SecretKeySpec(ByteArray(KEY_BYTES) { it.toByte() }, "AES")
}
}
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
/**
* Pins the single-source-of-truth authenticator mapping. [AuthenticatorPolicy.ACCEPTED] is the one
* definition; the two derived flag sets translate it into each Android API's own vocabulary. If a
* future change loosens or tightens one, both must move together — these assertions catch the drift
* that is otherwise only observable on a device (a BiometricPrompt that succeeds but a Keystore key
* that then throws UserNotAuthenticatedException at use).
*
* The referenced SDK constants are Java compile-time constants, so they inline into this test on the
* plain JVM — no Android runtime is needed to compare the values.
*/
class AuthenticatorPolicyTest {
@Test
fun `accepted set is a strong biometric or the device credential`() {
assertEquals(
setOf(AppAuthenticator.STRONG_BIOMETRIC, AppAuthenticator.DEVICE_CREDENTIAL),
AuthenticatorPolicy.ACCEPTED,
)
}
@Test
fun `maps to the BiometricManager vocabulary for the BiometricPrompt`() {
assertEquals(
BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL,
AuthenticatorPolicy.biometricPromptAuthenticators,
)
}
@Test
fun `maps to the KeyProperties vocabulary for the KeyGenParameterSpec`() {
assertEquals(
KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL,
AuthenticatorPolicy.keyGenAuthenticators,
)
}
@Test
fun `AppLockManager AUTHENTICATORS is the biometric-prompt mapping, not an independent copy`() {
assertEquals(AuthenticatorPolicy.biometricPromptAuthenticators, AppLockManager.AUTHENTICATORS)
}
@Test
fun `the two API vocabularies are genuinely different bit sets`() {
// Why a single shared Int would be a bug: the same concept has different flag values in each
// API, so the policy has to be mapped, not copied.
assertNotEquals(
AuthenticatorPolicy.biometricPromptAuthenticators,
AuthenticatorPolicy.keyGenAuthenticators,
)
}
}