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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue