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:
parent
10cb474906
commit
8e7352dcc4
5 changed files with 143 additions and 18 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue