diff --git a/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt b/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt index be37397..61e94be 100644 --- a/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt +++ b/app/src/main/kotlin/org/libremail/data/security/AesGcmKeystoreCipher.kt @@ -1,9 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.data.security +import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException import android.util.Base64 +import org.libremail.reporting.AppLog import java.security.GeneralSecurityException import java.security.KeyStore import javax.crypto.AEADBadTagException @@ -115,23 +118,52 @@ abstract class AesGcmKeystoreCipher(private val alias: String, private val gener // 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 } + existingKey() ?: generateKeyWithStrongBoxFallback() + } + + /** + * Mint the key, preferring the hardware **StrongBox** secure element (a dedicated tamper-resistant + * chip) so the non-exportable key is bound to the strongest keystore available. Devices without + * StrongBox report [StrongBoxUnavailableException] at generation time; we then regenerate a + * TEE-backed key so key creation still succeeds on every device. Both the master ([KeystoreCrypto]) + * and auth-bound ([DatabaseKeyCipher]) keys inherit this through the shared base. + */ + private fun generateKeyWithStrongBoxFallback(): SecretKey = try { + generateKey(strongBox = true) + } catch (e: StrongBoxUnavailableException) { + // Expected on devices with no StrongBox — not an error. PII-free (a device-capability fact). + AppLog.i(TAG, "StrongBox unavailable for Keystore alias '$alias'; using a TEE-backed key: ${e.message}") + generateKey(strongBox = false) + } + + /** + * Test seam over the raw Android Keystore key generation for a given [strongBox] preference. The + * real [KeyGenerator] is device-only, so JVM unit tests override this to exercise the StrongBox + * fallback in [generateKeyWithStrongBoxFallback] without a Keystore. + */ + protected open fun generateKey(strongBox: Boolean): SecretKey { val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - generator.init(keySpec()) - generator.generateKey() + generator.init(keySpec(strongBox)) + return generator.generateKey() } /** The alias-bound [KeyGenParameterSpec] for this key; subclasses extend [keySpecBuilder]. */ - protected abstract fun keySpec(): KeyGenParameterSpec + protected abstract fun keySpec(strongBox: Boolean): KeyGenParameterSpec /** The common AES-256-GCM builder (encrypt + decrypt, GCM, no padding, 256-bit) to extend. */ - protected fun keySpecBuilder(): KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder( + protected fun keySpecBuilder(strongBox: Boolean): 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) + .apply { + // Bind the key to the StrongBox secure element when requested and supported (API 28+; minSdk + // is 29, so the guard is defensive). If the device has no StrongBox, generateKey() catches + // StrongBoxUnavailableException and retries with strongBox = false for a TEE-backed key. + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setIsStrongBoxBacked(true) + } private companion object { const val ANDROID_KEYSTORE = "AndroidKeyStore" @@ -139,5 +171,6 @@ abstract class AesGcmKeystoreCipher(private val alias: String, private val gener const val IV_LENGTH = 12 const val TAG_BITS = 128 const val AES_KEY_SIZE_BITS = 256 + const val TAG = "AesGcmKeystoreCipher" } } diff --git a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt index 1d51852..9642132 100644 --- a/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt +++ b/app/src/main/kotlin/org/libremail/data/security/DatabaseKeyCipher.kt @@ -82,8 +82,8 @@ class DatabaseKeyCipher @Inject constructor() : /** 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") - override fun keySpec(): KeyGenParameterSpec { - val builder = keySpecBuilder() + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec { + val builder = keySpecBuilder(strongBox) .setUserAuthenticationRequired(true) .setInvalidatedByBiometricEnrollment(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt index 8844203..8f3dcea 100644 --- a/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt +++ b/app/src/main/kotlin/org/libremail/data/security/KeystoreCrypto.kt @@ -18,7 +18,7 @@ import javax.inject.Singleton @Singleton class KeystoreCrypto @Inject constructor() : AesGcmKeystoreCipher(alias = KEY_ALIAS, generateKeyOnDecrypt = true) { - override fun keySpec(): KeyGenParameterSpec = keySpecBuilder().build() + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = keySpecBuilder(strongBox).build() private companion object { const val KEY_ALIAS = "libremail.master.key" diff --git a/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt b/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt index b6438e8..e91eee8 100644 --- a/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt +++ b/app/src/test/kotlin/org/libremail/data/security/AesGcmKeystoreCipherTest.kt @@ -2,6 +2,11 @@ package org.libremail.data.security import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.StrongBoxUnavailableException +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import org.junit.Test import java.security.GeneralSecurityException import javax.crypto.AEADBadTagException @@ -70,6 +75,27 @@ class AesGcmKeystoreCipherTest { assertTrue(error.message!!.contains("test.alias")) } + @Test + fun `key generation falls back to a TEE-backed key when StrongBox is unavailable`() { + // AppLog.i (breadcrumb on the fallback) forwards to android.util.Log, a no-op stub under plain JVM + // tests; mock it (fully-qualified — a raw android.util.Log import is detekt-forbidden, epic #324). + mockkStatic(android.util.Log::class) + every { android.util.Log.i(any(), any()) } returns 0 + try { + val teeKey = newAesKey() + val cipher = StrongBoxFakeCipher(teeKey) + + val key = cipher.createKey() + + // StrongBox is attempted first; its StrongBoxUnavailableException triggers a single retry with + // strongBox = false, and that TEE-backed key is returned — so generation succeeds everywhere. + assertSame(teeKey, key) + assertEquals(listOf(true, false), cipher.attempts) + } finally { + unmockkStatic(android.util.Log::class) + } + } + @Test fun `a non-AEAD failure propagates unwrapped so key invalidation still surfaces`() { // Only AEADBadTagException is remapped; every other cipher failure — including the @@ -110,7 +136,36 @@ class AesGcmKeystoreCipherTest { return onDecrypt(key, encoded) } - override fun keySpec(): KeyGenParameterSpec = error("keySpec is not exercised in the JVM base test") + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = + error("keySpec is not exercised in the JVM base test") + } + + /** + * A JVM-only cipher that exercises the REAL [getOrCreateKey]/StrongBox-fallback control flow (unlike + * [FakeCipher], which stubs [getOrCreateKey] out). [existingKey] returns null so a key is generated, + * and the [generateKey] seam simulates the device: a StrongBox attempt fails, the TEE attempt yields + * [teeKey]. [attempts] records the `strongBox` value of each generation attempt in order. + */ + private class StrongBoxFakeCipher(private val teeKey: SecretKey) : + AesGcmKeystoreCipher(alias = "test.alias", generateKeyOnDecrypt = true) { + + val attempts = mutableListOf() + + override fun existingKey(): SecretKey? = null + + override fun generateKey(strongBox: Boolean): SecretKey { + attempts += strongBox + // Objenesis-instantiated (no stubbed-constructor call) so it is throwable under the android.jar + // stub; it is still a StrongBoxUnavailableException, so the production catch clause matches. + if (strongBox) throw mockk(relaxed = true) + return teeKey + } + + override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = + error("keySpec is bypassed because generateKey is overridden") + + /** Invokes the protected [getOrCreateKey] so the StrongBox fallback runs without touching Base64. */ + fun createKey(): SecretKey = getOrCreateKey() } private companion object {