mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 15:07:51 -05:00
Experimenting with Keystore
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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<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)
|
||||
@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"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
Reference in New Issue
Block a user