From 8e7352dcc469910b5e23f73f7096209f221e71a2 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 09:49:56 +0200 Subject: [PATCH] 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. --- .../AndroidKeystorePrivateKeysetStore.kt | 40 ++++++++++++++ .../app/data/security/E2EEKeyManager.kt | 44 +++++++++------ .../app/data/security/PrivateKeysetStore.kt | 16 ++++++ .../java/de/bollwerk/app/di/SecurityModule.kt | 7 +++ .../app/data/security/E2EEKeyManagerTest.kt | 54 +++++++++++++++++-- 5 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/de/bollwerk/app/data/security/AndroidKeystorePrivateKeysetStore.kt create mode 100644 app/src/main/java/de/bollwerk/app/data/security/PrivateKeysetStore.kt diff --git a/app/src/main/java/de/bollwerk/app/data/security/AndroidKeystorePrivateKeysetStore.kt b/app/src/main/java/de/bollwerk/app/data/security/AndroidKeystorePrivateKeysetStore.kt new file mode 100644 index 0000000..d9945cd --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/data/security/AndroidKeystorePrivateKeysetStore.kt @@ -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" + } +} diff --git a/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt b/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt index 12b8464..837ef65 100644 --- a/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt +++ b/app/src/main/java/de/bollwerk/app/data/security/E2EEKeyManager.kt @@ -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" + } } diff --git a/app/src/main/java/de/bollwerk/app/data/security/PrivateKeysetStore.kt b/app/src/main/java/de/bollwerk/app/data/security/PrivateKeysetStore.kt new file mode 100644 index 0000000..00d35e9 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/data/security/PrivateKeysetStore.kt @@ -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 +} diff --git a/app/src/main/java/de/bollwerk/app/di/SecurityModule.kt b/app/src/main/java/de/bollwerk/app/di/SecurityModule.kt index 4cfd7d9..2eab635 100644 --- a/app/src/main/java/de/bollwerk/app/di/SecurityModule.kt +++ b/app/src/main/java/de/bollwerk/app/di/SecurityModule.kt @@ -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) } diff --git a/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt b/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt index 76587fd..06f8c27 100644 --- a/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt +++ b/app/src/test/java/de/bollwerk/app/data/security/E2EEKeyManagerTest.kt @@ -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 + } +}