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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user