Experimenting with Keystore

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
This commit is contained in:
Arnau Mora
2025-05-26 11:27:40 +02:00
parent 979f2257de
commit a7b7d6d623
3 changed files with 177 additions and 45 deletions

View File

@@ -7,6 +7,7 @@ package at.bitfire.davdroid.webdav
import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.Credentials
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Before import org.junit.Before
@@ -29,7 +30,7 @@ class CredentialsStoreTest {
} }
@Test @Test
fun testSetGetDelete() { fun testSetGetDelete() = runTest {
store.setCredentials(0, Credentials(username = "myname", password = "12345")) store.setCredentials(0, Credentials(username = "myname", password = "12345"))
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0)) assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))

View File

@@ -5,18 +5,182 @@
package at.bitfire.davdroid.webdav package at.bitfire.davdroid.webdav
import android.content.Context import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.annotation.StringDef import androidx.annotation.StringDef
import androidx.core.content.edit import androidx.datastore.core.DataStore
import androidx.security.crypto.EncryptedSharedPreferences import androidx.datastore.preferences.core.MutablePreferences
import androidx.security.crypto.MasterKey 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 at.bitfire.davdroid.db.Credentials
import dagger.hilt.android.qualifiers.ApplicationContext 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 javax.inject.Inject
import kotlin.reflect.KClass
class CredentialsStore @Inject constructor( class CredentialsStore @Inject constructor(
@ApplicationContext context: Context @ApplicationContext context: Context
) { ) {
private val Context.dataStore: DataStore<Preferences> 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<ByteArray, ByteArray> {
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<Boolean>(mountId, HAS_CREDENTIALS)
val userNameKey = preferenceKey<String>(mountId, USER_NAME)
val passwordKey = preferenceKey<String>(mountId, PASSWORD)
val cerAliasKey = preferenceKey<String>(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<Boolean>(mountId, HAS_CREDENTIALS)
val userNameKey = preferenceKey<String>(mountId, USER_NAME)
val passwordKey = preferenceKey<String>(mountId, PASSWORD)
val cerAliasKey = preferenceKey<String>(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 <Type : Any> preferenceKeyFromGeneric(keyName: String, type: KClass<Type>): Preferences.Key<Type> {
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<Type>
}
private fun <Type : Any> preferenceKey(
mountId: Long,
@KeyName name: String,
suffix: String = "",
type: KClass<Type>,
): Preferences.Key<Type> {
val keyName = "$mountId.$name$suffix"
return preferenceKeyFromGeneric(keyName, type)
}
private inline fun <reified Type : Any> preferenceKey(
mountId: Long,
@KeyName name: String,
suffix: String = "",
): Preferences.Key<Type> = preferenceKey(mountId, name, suffix, Type::class)
private fun <Type : Any> Preferences.Key<Type>.iv(): Preferences.Key<String> {
return preferenceKeyFromGeneric("$name.iv", String::class)
}
private inline fun <reified Type : Any> MutablePreferences.setWithIV(key: Preferences.Key<Type>, value: Type, iv: ByteArray) {
set(key, value)
set(key.iv(), iv.decodeToString())
}
private inline fun <reified Type : Any> Preferences.getWithIV(key: Preferences.Key<Type>): Pair<Type, ByteArray>? {
val value = get(key)
val iv = get(key.iv())?.encodeToByteArray()
return if (value != null && iv != null) value to iv
else null
}
@Retention(AnnotationRetention.SOURCE) @Retention(AnnotationRetention.SOURCE)
@StringDef( @StringDef(
HAS_CREDENTIALS, HAS_CREDENTIALS,
@@ -31,44 +195,10 @@ class CredentialsStore @Inject constructor(
const val USER_NAME = "user_name" const val USER_NAME = "user_name"
const val PASSWORD = "password" const val PASSWORD = "password"
const val CERTIFICATE_ALIAS = "certificate_alias" 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"
}

View File

@@ -68,6 +68,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
@@ -765,7 +766,7 @@ class DavDocumentsProvider(
* @param mountId ID of the mount to access * @param mountId ID of the mount to access
* @param logBody whether to log the body of HTTP requests (disable for potentially large files) * @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() val builder = httpClientBuilder.get()
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS) .loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
.setCookieStore( .setCookieStore(
@@ -776,7 +777,7 @@ class DavDocumentsProvider(
builder.authenticate(host = null, credentials = credentials) builder.authenticate(host = null, credentials = credentials)
} }
return builder.build() builder.build()
} }
internal fun notifyFolderChanged(parentDocumentId: Long?) { internal fun notifyFolderChanged(parentDocumentId: Long?) {