feat(security): replace CleartextKeysetHandle with AndroidKeysetManager (#105)

- Extract PrivateKeysetStore interface for testability
- Add AndroidKeystorePrivateKeysetStore (Android Keystore-backed AEAD)
- Refactor E2EEKeyManager to use PrivateKeysetStore
- Add legacy migration: old cleartext key is removed, forcing re-generation
- Update DI module to provide AndroidKeystorePrivateKeysetStore
- Adapt unit tests with FakePrivateKeysetStore + migration test

Private key material no longer appears as cleartext JSON on the JVM heap.
Existing devices with legacy keys will re-generate and re-upload via
EnsureKeyPairUseCase on next app launch.
This commit is contained in:
Jens Reinemann 2026-05-18 09:49:56 +02:00
parent 10cb474906
commit 8e7352dcc4
5 changed files with 143 additions and 18 deletions

View file

@ -0,0 +1,40 @@
package de.bollwerk.app.data.security
import android.content.Context
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.integration.android.AndroidKeysetManager
/// Stores the private Tink keyset encrypted via Android Keystore.
/// The raw key material never appears as cleartext on the JVM heap.
internal class AndroidKeystorePrivateKeysetStore(
private val context: Context
) : PrivateKeysetStore {
override fun hasKeyset(): Boolean {
val prefs = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE)
return prefs.contains(KEYSET_NAME)
}
override fun getKeysetHandle(): KeysetHandle =
AndroidKeysetManager.Builder()
.withSharedPref(context, KEYSET_NAME, PREF_FILE_NAME)
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.keysetHandle
override fun generateAndStoreKeyset(): KeysetHandle =
AndroidKeysetManager.Builder()
.withSharedPref(context, KEYSET_NAME, PREF_FILE_NAME)
.withKeyTemplate(KeyTemplates.get(KEY_TEMPLATE))
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.keysetHandle
private companion object {
const val PREF_FILE_NAME = "bollwerk_e2ee_keyset_prefs"
const val KEYSET_NAME = "bollwerk_e2ee_private_keyset"
const val MASTER_KEY_URI = "android-keystore://bollwerk_e2ee_master_key"
const val KEY_TEMPLATE = "DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_CHACHA20_POLY1305"
}
}

View file

@ -1,11 +1,11 @@
package de.bollwerk.app.data.security
import android.util.Log
import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.HybridDecrypt
import com.google.crypto.tink.HybridEncrypt
import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.hybrid.HybridConfig
import de.bollwerk.app.domain.model.SettingsKey.StringKey
@ -16,25 +16,22 @@ import javax.inject.Singleton
@Singleton
internal class E2EEKeyManager @Inject constructor(
private val privateKeysetStore: PrivateKeysetStore,
private val secureStorage: SecureTokenStorage
) {
init {
HybridConfig.register()
migrateFromLegacyIfNeeded()
}
fun hasKeyPair(): Boolean =
secureStorage.get(StringKey.E2EEPrivateKeyset.key) != null
fun hasKeyPair(): Boolean = privateKeysetStore.hasKeyset()
fun generateAndStoreKeyPair() {
val template = KeyTemplates.get("DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_CHACHA20_POLY1305")
val privateHandle = KeysetHandle.generateNew(template)
val privateKeysetJson = serializeKeysetToJson(privateHandle)
secureStorage.set(StringKey.E2EEPrivateKeyset.key, privateKeysetJson)
val privateHandle = privateKeysetStore.generateAndStoreKeyset()
val publicHandle = privateHandle.publicKeysetHandle
val publicKeysetJson = serializeKeysetToJson(publicHandle)
val publicKeysetJson = serializePublicKeyToJson(publicHandle)
val publicKeyBase64 = Base64.getEncoder().encodeToString(
publicKeysetJson.toByteArray(Charsets.UTF_8)
)
@ -49,28 +46,45 @@ internal class E2EEKeyManager @Inject constructor(
Base64.getDecoder().decode(recipientPublicKeyBase64),
Charsets.UTF_8
)
val publicHandle = deserializeKeysetFromJson(publicKeysetJson)
val publicHandle = deserializePublicKeyFromJson(publicKeysetJson)
val hybridEncrypt = publicHandle.getPrimitive(HybridEncrypt::class.java)
val ciphertext = hybridEncrypt.encrypt(plaintext.toByteArray(Charsets.UTF_8), null)
return Base64.getEncoder().encodeToString(ciphertext)
}
fun decryptMessage(ciphertextBase64: String): String {
val privateKeysetJson = secureStorage.get(StringKey.E2EEPrivateKeyset.key)
?: throw IllegalStateException("E2EE: No private key available for decryption")
val privateHandle = deserializeKeysetFromJson(privateKeysetJson)
val privateHandle = privateKeysetStore.getKeysetHandle()
val hybridDecrypt = privateHandle.getPrimitive(HybridDecrypt::class.java)
val ciphertext = Base64.getDecoder().decode(ciphertextBase64)
val plaintextBytes = hybridDecrypt.decrypt(ciphertext, null)
return String(plaintextBytes, Charsets.UTF_8)
}
private fun serializeKeysetToJson(handle: KeysetHandle): String {
/// Detects legacy cleartext private key in SecureTokenStorage and removes it.
/// The key cannot be imported into AndroidKeysetManager, so hasKeyPair() returns false
/// afterwards, triggering re-generation + server upload via EnsureKeyPairUseCase.
private fun migrateFromLegacyIfNeeded() {
if (secureStorage.get(StringKey.E2EEPrivateKeyset.key) == null) return
if (!privateKeysetStore.hasKeyset()) {
Log.i(TAG, "E2EE: Legacy cleartext private key detected will be re-generated")
}
// Remove legacy cleartext key regardless it must not persist
secureStorage.remove(StringKey.E2EEPrivateKeyset.key)
// Remove stale public key so it gets re-generated and re-uploaded
secureStorage.remove(StringKey.E2EEPublicKeyBase64.key)
}
private fun serializePublicKeyToJson(handle: KeysetHandle): String {
val outputStream = ByteArrayOutputStream()
CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(outputStream))
return outputStream.toString(Charsets.UTF_8.name())
}
private fun deserializeKeysetFromJson(json: String): KeysetHandle =
private fun deserializePublicKeyFromJson(json: String): KeysetHandle =
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
private companion object {
const val TAG = "E2EEKeyManager"
}
}

View file

@ -0,0 +1,16 @@
package de.bollwerk.app.data.security
import com.google.crypto.tink.KeysetHandle
/// Abstraction for private keyset storage.
/// Production uses Android Keystore; tests use in-memory storage.
internal interface PrivateKeysetStore {
/// Returns true if a private keyset has been stored.
fun hasKeyset(): Boolean
/// Returns the stored keyset handle. Throws if none exists.
fun getKeysetHandle(): KeysetHandle
/// Generates a new keyset, stores it, and returns the handle.
fun generateAndStoreKeyset(): KeysetHandle
}

View file

@ -6,7 +6,9 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.bollwerk.app.data.security.AndroidKeystorePrivateKeysetStore
import de.bollwerk.app.data.security.EncryptedPrefsTokenStorage
import de.bollwerk.app.data.security.PrivateKeysetStore
import de.bollwerk.app.data.security.SecureTokenStorage
import javax.inject.Singleton
@ -18,4 +20,9 @@ internal object SecurityModule {
@Singleton
fun provideSecureTokenStorage(@ApplicationContext context: Context): SecureTokenStorage =
EncryptedPrefsTokenStorage(context)
@Provides
@Singleton
fun providePrivateKeysetStore(@ApplicationContext context: Context): PrivateKeysetStore =
AndroidKeystorePrivateKeysetStore(context)
}

View file

@ -1,5 +1,11 @@
package de.bollwerk.app.data.security
import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.hybrid.HybridConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
@ -7,21 +13,25 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayOutputStream
class E2EEKeyManagerTest {
private lateinit var storage: FakeSecureTokenStorage
private lateinit var privateKeysetStore: FakePrivateKeysetStore
private lateinit var keyManager: E2EEKeyManager
@Before
fun setUp() {
HybridConfig.register()
storage = FakeSecureTokenStorage()
keyManager = E2EEKeyManager(storage)
privateKeysetStore = FakePrivateKeysetStore()
keyManager = E2EEKeyManager(privateKeysetStore, storage)
}
@Test
fun test_hasKeyPair_noKeyStored_returnsFalse() {
// Given: empty storage
// Given: empty store
// When
val result = keyManager.hasKeyPair()
@ -77,7 +87,8 @@ class E2EEKeyManagerTest {
fun test_decryptMessage_wrongKey_throwsException() {
// Given: two separate key managers with different keys
val storage2 = FakeSecureTokenStorage()
val otherKeyManager = E2EEKeyManager(storage2)
val store2 = FakePrivateKeysetStore()
val otherKeyManager = E2EEKeyManager(store2, storage2)
keyManager.generateAndStoreKeyPair()
otherKeyManager.generateAndStoreKeyPair()
@ -92,6 +103,27 @@ class E2EEKeyManagerTest {
// Then: should throw an Exception
keyManager.decryptMessage(encryptedForOther)
}
@Test
fun test_migrateFromLegacy_removesOldCleartextKey() {
// Given: legacy cleartext key stored in SecureTokenStorage
val legacyHandle = KeysetHandle.generateNew(
KeyTemplates.get("DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_CHACHA20_POLY1305")
)
val out = ByteArrayOutputStream()
CleartextKeysetHandle.write(legacyHandle, JsonKeysetWriter.withOutputStream(out))
storage.set("e2ee_private_keyset", out.toString(Charsets.UTF_8.name()))
storage.set("e2ee_public_key", "old_public_key_base64")
// When: creating a new key manager triggers migration
val freshStore = FakePrivateKeysetStore()
val migratedManager = E2EEKeyManager(freshStore, storage)
// Then: legacy keys are removed, hasKeyPair is false (forces re-generation)
assertFalse(migratedManager.hasKeyPair())
assertEquals(null, storage.get("e2ee_private_keyset"))
assertEquals(null, storage.get("e2ee_public_key"))
}
}
private class FakeSecureTokenStorage : SecureTokenStorage {
@ -101,3 +133,19 @@ private class FakeSecureTokenStorage : SecureTokenStorage {
override fun set(key: String, value: String) { store[key] = value }
override fun remove(key: String) { store.remove(key) }
}
private class FakePrivateKeysetStore : PrivateKeysetStore {
private var storedKeyset: KeysetHandle? = null
override fun hasKeyset(): Boolean = storedKeyset != null
override fun getKeysetHandle(): KeysetHandle =
storedKeyset ?: throw IllegalStateException("No keyset stored")
override fun generateAndStoreKeyset(): KeysetHandle {
val template = KeyTemplates.get("DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_CHACHA20_POLY1305")
val handle = KeysetHandle.generateNew(template)
storedKeyset = handle
return handle
}
}