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 package de.bollwerk.app.data.security
import android.util.Log
import com.google.crypto.tink.CleartextKeysetHandle import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.HybridDecrypt import com.google.crypto.tink.HybridDecrypt
import com.google.crypto.tink.HybridEncrypt import com.google.crypto.tink.HybridEncrypt
import com.google.crypto.tink.JsonKeysetReader import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.KeysetHandle import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.hybrid.HybridConfig import com.google.crypto.tink.hybrid.HybridConfig
import de.bollwerk.app.domain.model.SettingsKey.StringKey import de.bollwerk.app.domain.model.SettingsKey.StringKey
@ -16,25 +16,22 @@ import javax.inject.Singleton
@Singleton @Singleton
internal class E2EEKeyManager @Inject constructor( internal class E2EEKeyManager @Inject constructor(
private val privateKeysetStore: PrivateKeysetStore,
private val secureStorage: SecureTokenStorage private val secureStorage: SecureTokenStorage
) { ) {
init { init {
HybridConfig.register() HybridConfig.register()
migrateFromLegacyIfNeeded()
} }
fun hasKeyPair(): Boolean = fun hasKeyPair(): Boolean = privateKeysetStore.hasKeyset()
secureStorage.get(StringKey.E2EEPrivateKeyset.key) != null
fun generateAndStoreKeyPair() { fun generateAndStoreKeyPair() {
val template = KeyTemplates.get("DHKEM_X25519_HKDF_SHA256_HKDF_SHA256_CHACHA20_POLY1305") val privateHandle = privateKeysetStore.generateAndStoreKeyset()
val privateHandle = KeysetHandle.generateNew(template)
val privateKeysetJson = serializeKeysetToJson(privateHandle)
secureStorage.set(StringKey.E2EEPrivateKeyset.key, privateKeysetJson)
val publicHandle = privateHandle.publicKeysetHandle val publicHandle = privateHandle.publicKeysetHandle
val publicKeysetJson = serializeKeysetToJson(publicHandle) val publicKeysetJson = serializePublicKeyToJson(publicHandle)
val publicKeyBase64 = Base64.getEncoder().encodeToString( val publicKeyBase64 = Base64.getEncoder().encodeToString(
publicKeysetJson.toByteArray(Charsets.UTF_8) publicKeysetJson.toByteArray(Charsets.UTF_8)
) )
@ -49,28 +46,45 @@ internal class E2EEKeyManager @Inject constructor(
Base64.getDecoder().decode(recipientPublicKeyBase64), Base64.getDecoder().decode(recipientPublicKeyBase64),
Charsets.UTF_8 Charsets.UTF_8
) )
val publicHandle = deserializeKeysetFromJson(publicKeysetJson) val publicHandle = deserializePublicKeyFromJson(publicKeysetJson)
val hybridEncrypt = publicHandle.getPrimitive(HybridEncrypt::class.java) val hybridEncrypt = publicHandle.getPrimitive(HybridEncrypt::class.java)
val ciphertext = hybridEncrypt.encrypt(plaintext.toByteArray(Charsets.UTF_8), null) val ciphertext = hybridEncrypt.encrypt(plaintext.toByteArray(Charsets.UTF_8), null)
return Base64.getEncoder().encodeToString(ciphertext) return Base64.getEncoder().encodeToString(ciphertext)
} }
fun decryptMessage(ciphertextBase64: String): String { fun decryptMessage(ciphertextBase64: String): String {
val privateKeysetJson = secureStorage.get(StringKey.E2EEPrivateKeyset.key) val privateHandle = privateKeysetStore.getKeysetHandle()
?: throw IllegalStateException("E2EE: No private key available for decryption")
val privateHandle = deserializeKeysetFromJson(privateKeysetJson)
val hybridDecrypt = privateHandle.getPrimitive(HybridDecrypt::class.java) val hybridDecrypt = privateHandle.getPrimitive(HybridDecrypt::class.java)
val ciphertext = Base64.getDecoder().decode(ciphertextBase64) val ciphertext = Base64.getDecoder().decode(ciphertextBase64)
val plaintextBytes = hybridDecrypt.decrypt(ciphertext, null) val plaintextBytes = hybridDecrypt.decrypt(ciphertext, null)
return String(plaintextBytes, Charsets.UTF_8) 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() val outputStream = ByteArrayOutputStream()
CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(outputStream)) CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(outputStream))
return outputStream.toString(Charsets.UTF_8.name()) return outputStream.toString(Charsets.UTF_8.name())
} }
private fun deserializeKeysetFromJson(json: String): KeysetHandle = private fun deserializePublicKeyFromJson(json: String): KeysetHandle =
CleartextKeysetHandle.read(JsonKeysetReader.withString(json)) 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.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent 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.EncryptedPrefsTokenStorage
import de.bollwerk.app.data.security.PrivateKeysetStore
import de.bollwerk.app.data.security.SecureTokenStorage import de.bollwerk.app.data.security.SecureTokenStorage
import javax.inject.Singleton import javax.inject.Singleton
@ -18,4 +20,9 @@ internal object SecurityModule {
@Singleton @Singleton
fun provideSecureTokenStorage(@ApplicationContext context: Context): SecureTokenStorage = fun provideSecureTokenStorage(@ApplicationContext context: Context): SecureTokenStorage =
EncryptedPrefsTokenStorage(context) EncryptedPrefsTokenStorage(context)
@Provides
@Singleton
fun providePrivateKeysetStore(@ApplicationContext context: Context): PrivateKeysetStore =
AndroidKeystorePrivateKeysetStore(context)
} }

View file

@ -1,5 +1,11 @@
package de.bollwerk.app.data.security 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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
@ -7,21 +13,25 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.ByteArrayOutputStream
class E2EEKeyManagerTest { class E2EEKeyManagerTest {
private lateinit var storage: FakeSecureTokenStorage private lateinit var storage: FakeSecureTokenStorage
private lateinit var privateKeysetStore: FakePrivateKeysetStore
private lateinit var keyManager: E2EEKeyManager private lateinit var keyManager: E2EEKeyManager
@Before @Before
fun setUp() { fun setUp() {
HybridConfig.register()
storage = FakeSecureTokenStorage() storage = FakeSecureTokenStorage()
keyManager = E2EEKeyManager(storage) privateKeysetStore = FakePrivateKeysetStore()
keyManager = E2EEKeyManager(privateKeysetStore, storage)
} }
@Test @Test
fun test_hasKeyPair_noKeyStored_returnsFalse() { fun test_hasKeyPair_noKeyStored_returnsFalse() {
// Given: empty storage // Given: empty store
// When // When
val result = keyManager.hasKeyPair() val result = keyManager.hasKeyPair()
@ -77,7 +87,8 @@ class E2EEKeyManagerTest {
fun test_decryptMessage_wrongKey_throwsException() { fun test_decryptMessage_wrongKey_throwsException() {
// Given: two separate key managers with different keys // Given: two separate key managers with different keys
val storage2 = FakeSecureTokenStorage() val storage2 = FakeSecureTokenStorage()
val otherKeyManager = E2EEKeyManager(storage2) val store2 = FakePrivateKeysetStore()
val otherKeyManager = E2EEKeyManager(store2, storage2)
keyManager.generateAndStoreKeyPair() keyManager.generateAndStoreKeyPair()
otherKeyManager.generateAndStoreKeyPair() otherKeyManager.generateAndStoreKeyPair()
@ -92,6 +103,27 @@ class E2EEKeyManagerTest {
// Then: should throw an Exception // Then: should throw an Exception
keyManager.decryptMessage(encryptedForOther) 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 { private class FakeSecureTokenStorage : SecureTokenStorage {
@ -101,3 +133,19 @@ private class FakeSecureTokenStorage : SecureTokenStorage {
override fun set(key: String, value: String) { store[key] = value } override fun set(key: String, value: String) { store[key] = value }
override fun remove(key: String) { store.remove(key) } 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
}
}