From a7b7d6d62359d312abe0b0887505ee17eb9f1fd0 Mon Sep 17 00:00:00 2001 From: Arnau Mora Date: Mon, 26 May 2025 11:27:40 +0200 Subject: [PATCH] Experimenting with Keystore Signed-off-by: Arnau Mora --- .../davdroid/webdav/CredentialsStoreTest.kt | 3 +- .../davdroid/webdav/CredentialsStore.kt | 214 ++++++++++++++---- .../davdroid/webdav/DavDocumentsProvider.kt | 5 +- 3 files changed, 177 insertions(+), 45 deletions(-) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt index c3c2bcea3..5d2cd7e7d 100644 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/CredentialsStoreTest.kt @@ -7,6 +7,7 @@ package at.bitfire.davdroid.webdav import at.bitfire.davdroid.db.Credentials import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Before @@ -29,7 +30,7 @@ class CredentialsStoreTest { } @Test - fun testSetGetDelete() { + fun testSetGetDelete() = runTest { store.setCredentials(0, Credentials(username = "myname", password = "12345")) assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0)) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt index d4e0d1945..957d8a280 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/CredentialsStore.kt @@ -5,18 +5,182 @@ package at.bitfire.davdroid.webdav import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties import androidx.annotation.StringDef -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import at.bitfire.davdroid.db.Credentials import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import java.security.Key +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec import javax.inject.Inject +import kotlin.reflect.KClass class CredentialsStore @Inject constructor( @ApplicationContext context: Context ) { + private val Context.dataStore: DataStore by preferencesDataStore(name = "credentials") + private val dataStore by lazy { context.dataStore } + + val ks: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + + /* + * Generate a new EC key pair entry in the Android Keystore by + * using the KeyPairGenerator API. The private key can only be + * used for signing or verification and only with SHA-256 or + * SHA-512 as the message digest. + */ + private fun generateSecretKey(alias: String): SecretKey { + val keyEntry = ks.getEntry(alias, null) + if (keyEntry == null) { + val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ).run { + setBlockModes(KeyProperties.BLOCK_MODE_CBC) + setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + build() + } + kg.init(parameterSpec) + + ks.setKeyEntry(alias, kg.generateKey(), null, null) + } + + return getSecretKey(alias) ?: error("There was an error while generating the key") + } + + private fun getSecretKey(alias: String): SecretKey? { + return ks.getKey(alias, null) as SecretKey? + } + + private fun encrypt(key: Key, data: String): Pair { + return Cipher.getInstance(AES_MODE).apply { + init(Cipher.ENCRYPT_MODE, key) + }.let { it.doFinal(data.encodeToByteArray()) to it.iv } + } + + private fun decrypt(key: Key, encryptedData: ByteArray, iv: ByteArray): String { + return Cipher.getInstance(AES_MODE).apply { + init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) + }.doFinal(encryptedData).decodeToString() + } + + suspend fun getCredentials(mountId: Long): Credentials? { + val hasCredentialsKey = preferenceKey(mountId, HAS_CREDENTIALS) + val userNameKey = preferenceKey(mountId, USER_NAME) + val passwordKey = preferenceKey(mountId, PASSWORD) + val cerAliasKey = preferenceKey(mountId, CERTIFICATE_ALIAS) + + val data = dataStore.data.first() + if (data[hasCredentialsKey] != true) return null + + val key = getSecretKey(mountId.toString()) + checkNotNull(key) { "Could not find any key for mount $mountId" } + + val username = data.getWithIV(userNameKey)?.let { (value, iv) -> decrypt(key, value.encodeToByteArray(), iv) } + val password = data.getWithIV(passwordKey)?.let { (value, iv) -> decrypt(key, value.encodeToByteArray(), iv) } + val cerAlias = data.getWithIV(cerAliasKey)?.let { (value, iv) -> decrypt(key, value.encodeToByteArray(), iv) } + + return Credentials(username, password, cerAlias) + } + + suspend fun setCredentials(mountId: Long, credentials: Credentials?) { + val alias = mountId.toString() + val key = getSecretKey(alias) ?: generateSecretKey(alias) + + dataStore.edit { pref -> + val hasCredentials = preferenceKey(mountId, HAS_CREDENTIALS) + val userNameKey = preferenceKey(mountId, USER_NAME) + val passwordKey = preferenceKey(mountId, PASSWORD) + val cerAliasKey = preferenceKey(mountId, CERTIFICATE_ALIAS) + + if (credentials == null) { + pref.remove(hasCredentials) + pref.remove(userNameKey) + pref.remove(userNameKey.iv()) + pref.remove(passwordKey) + pref.remove(passwordKey.iv()) + pref.remove(cerAliasKey) + pref.remove(cerAliasKey.iv()) + } else { + check(credentials.username != null || credentials.password != null || credentials.certificateAlias != null) { + "Credentials given are all-null" + } + + credentials.username?.let { username -> + val (data, iv) = encrypt(key, username) + pref.setWithIV(userNameKey, data.decodeToString(), iv) + } + credentials.password?.let { password -> + val (data, iv) = encrypt(key, password) + pref.setWithIV(passwordKey, data.decodeToString(), iv) + } + credentials.certificateAlias?.let { certificateAlias -> + val (data, iv) = encrypt(key, certificateAlias) + pref.setWithIV(cerAliasKey, data.decodeToString(), iv) + } + + pref[hasCredentials] = true + } + } + } + + private fun preferenceKeyFromGeneric(keyName: String, type: KClass): Preferences.Key { + val key = when (type) { + String::class -> stringPreferencesKey(keyName) + Boolean::class -> booleanPreferencesKey(keyName) + else -> error("Got unsupported type ${type.simpleName}") + } + @Suppress("UNCHECKED_CAST") + return key as Preferences.Key + } + + private fun preferenceKey( + mountId: Long, + @KeyName name: String, + suffix: String = "", + type: KClass, + ): Preferences.Key { + val keyName = "$mountId.$name$suffix" + return preferenceKeyFromGeneric(keyName, type) + } + + private inline fun preferenceKey( + mountId: Long, + @KeyName name: String, + suffix: String = "", + ): Preferences.Key = preferenceKey(mountId, name, suffix, Type::class) + + private fun Preferences.Key.iv(): Preferences.Key { + return preferenceKeyFromGeneric("$name.iv", String::class) + } + + private inline fun MutablePreferences.setWithIV(key: Preferences.Key, value: Type, iv: ByteArray) { + set(key, value) + set(key.iv(), iv.decodeToString()) + } + + private inline fun Preferences.getWithIV(key: Preferences.Key): Pair? { + val value = get(key) + val iv = get(key.iv())?.encodeToByteArray() + return if (value != null && iv != null) value to iv + else null + } + + @Retention(AnnotationRetention.SOURCE) @StringDef( HAS_CREDENTIALS, @@ -31,44 +195,10 @@ class CredentialsStore @Inject constructor( const val USER_NAME = "user_name" const val PASSWORD = "password" const val CERTIFICATE_ALIAS = "certificate_alias" + + const val KEYSTORE_PROVIDER = "AndroidKeyStore" + + const val AES_MODE = KeyProperties.KEY_ALGORITHM_AES + "/" + KeyProperties.BLOCK_MODE_CBC + "/" + KeyProperties.ENCRYPTION_PADDING_PKCS7 } - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - private val prefs = EncryptedSharedPreferences.create(context, "webdav_credentials", masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) - - - fun getCredentials(mountId: Long): Credentials? { - if (!prefs.getBoolean(keyName(mountId, HAS_CREDENTIALS), false)) - return null - - return Credentials( - prefs.getString(keyName(mountId, USER_NAME), null), - prefs.getString(keyName(mountId, PASSWORD), null), - prefs.getString(keyName(mountId, CERTIFICATE_ALIAS), null) - ) - } - - fun setCredentials(mountId: Long, credentials: Credentials?) { - prefs.edit { - if (credentials != null) - putBoolean(keyName(mountId, HAS_CREDENTIALS), true) - .putString(keyName(mountId, USER_NAME), credentials.username) - .putString(keyName(mountId, PASSWORD), credentials.password) - .putString(keyName(mountId, CERTIFICATE_ALIAS), credentials.certificateAlias) - else - remove(keyName(mountId, HAS_CREDENTIALS)) - .remove(keyName(mountId, USER_NAME)) - .remove(keyName(mountId, PASSWORD)) - .remove(keyName(mountId, CERTIFICATE_ALIAS)) - } - } - - - private fun keyName(mountId: Long, @KeyName name: String) = - "$mountId.$name" - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt index e50e08b21..98208fdca 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/DavDocumentsProvider.kt @@ -68,6 +68,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import okhttp3.CookieJar import okhttp3.HttpUrl @@ -765,7 +766,7 @@ class DavDocumentsProvider( * @param mountId ID of the mount to access * @param logBody whether to log the body of HTTP requests (disable for potentially large files) */ - internal fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient { + internal suspend fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient = withContext(Dispatchers.IO) { val builder = httpClientBuilder.get() .loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS) .setCookieStore( @@ -776,7 +777,7 @@ class DavDocumentsProvider( builder.authenticate(host = null, credentials = credentials) } - return builder.build() + builder.build() } internal fun notifyFolderChanged(parentDocumentId: Long?) {