feat(security): back cache-key Keystore keys with StrongBox, fall back to TEE (#359)

Bind the non-exportable AES-256-GCM keys that seal the SQLCipher cache
passphrase to the hardware StrongBox secure element when the device has
one. Applied in the single shared place, AesGcmKeystoreCipher, so it
covers both the master (KeystoreCrypto) and auth-bound (DatabaseKeyCipher)
keys.

Devices without StrongBox throw StrongBoxUnavailableException at
KeyGenerator.generateKey(); a new generate-with-fallback path catches it
and regenerates a TEE-backed key so key creation still succeeds
everywhere. Guarded on API 28+ (minSdk is 29). Framing, seal/unseal, and
the missing-key policies are unchanged; the passphrase is still never
plaintext at rest and never logged.

Adds a JVM regression test for the StrongBox->TEE fallback via the
existing test seams (existingKey / a new generateKey seam).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:46:03 -05:00
co-authored by Claude Opus 4.8
parent 5c715e1676
commit 551a2df66f
4 changed files with 97 additions and 9 deletions
@@ -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"
}
}
@@ -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) {
@@ -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"
@@ -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<String>(), any<String>()) } 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<Boolean>()
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<StrongBoxUnavailableException>(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 {