From 0fb7343367f0fcba40520a407ba10c77c5d3f6a0 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Fri, 20 Sep 2024 21:23:01 -0700 Subject: [PATCH 01/18] Add SpeziStorage --- .../storage/local/LocalStorageTests.kt | 26 ++++ .../modules/storage/local/LocalStorage.kt | 126 ++++++++++++++++++ .../storage/local/LocalStorageError.kt | 13 ++ .../storage/local/LocalStorageSetting.kt | 58 ++++++++ .../modules/storage/secure/Credentials.kt | 6 + .../modules/storage/secure/SecureStorage.kt | 88 ++++++++++++ .../storage/secure/SecureStorageError.kt | 13 ++ .../storage/secure/SecureStorageItemTypes.kt | 36 +++++ .../storage/secure/SecureStorageScope.kt | 41 ++++++ 9 files changed, 407 insertions(+) create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt new file mode 100644 index 000000000..ca23260d0 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt @@ -0,0 +1,26 @@ +package edu.stanford.spezi.modules.storage.local + +import org.junit.Test +import kotlin.random.Random + +class LocalStorageTests { + data class Letter(val greeting: String) + + @Test + fun localStorage() { + val localStorage = LocalStorage() + + var greeting = "Hello Paul 👋" + for (index in 0..Random.nextInt(10)) { + greeting += "🚀" + } + val letter = Letter(greeting = greeting) + localStorage.store(letter, settings = LocalStorageSetting.Unencrypted) + val storedLetter: Letter = localStorage.read(settings = LocalStorageSetting.Unencrypted) + + assert(letter.greeting == storedLetter.greeting) + + localStorage.delete(Letter::class) + localStorage.delete(storageKey = "Letter") + } +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt new file mode 100644 index 000000000..3651285ce --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -0,0 +1,126 @@ +package edu.stanford.spezi.modules.storage.local + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.spezi.core.coroutines.di.Dispatching +import edu.stanford.spezi.modules.storage.secure.SecureStorage +import kotlinx.coroutines.CoroutineDispatcher +import java.io.File +import javax.inject.Inject +import kotlin.reflect.KClass + +class LocalStorage @Inject constructor( + @ApplicationContext val context: Context, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, + ) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + private val secureStorage = SecureStorage() + + private inline fun store( + // TODO: iOS has this only as a private helper function + element: C, + storageKey: String?, + settings: LocalStorageSetting, + encode: (C) -> ByteArray + ): Unit { + val file = file(storageKey, C::class) + + val alreadyExistedBefore = file.exists() + + // Called at the end of each execution path + // We can not use defer as the function can potentially throw an error. + + + val data = encode(element) + + val keys = settings.keys(secureStorage) + + // Determine if the data should be encrypted or not: + if (keys == null) { + file.writeBytes(data) + setResourceValues(alreadyExistedBefore, settings, file) + return + } + + // TODO: Check if encryption is supported + // + // iOS: + // // Encryption enabled: + // guard SecKeyIsAlgorithmSupported (keys.publicKey, .encrypt, encryptionAlgorithm) else { + // throw LocalStorageError.encryptionNotPossible + // } + // + // var encryptError: Unmanaged? + // guard let encryptedData = SecKeyCreateEncryptedData( + // keys.publicKey, + // encryptionAlgorithm, + // data as CFData, & encryptError) as Data? else { + // throw LocalStorageError.encryptionNotPossible + // } + + val encryptedData = data + file.writeBytes(encryptedData) + setResourceValues(alreadyExistedBefore, settings, file) + } + + private inline fun read( // TODO: iOS only has this as a private helper + storageKey: String?, + settings: LocalStorageSetting, + decode: (ByteArray) -> C + ): C { + val file = file(storageKey, C::class) + val keys = settings.keys(secureStorage = secureStorage) + ?: return decode(file.readBytes()) + + val privateKey = keys.first + val publicKey = keys.second + + // TODO: iOS decryption: + // guard SecKeyIsAlgorithmSupported(keys.privateKey, .decrypt, encryptionAlgorithm) else { + // throw LocalStorageError.decryptionNotPossible + // } + + // var decryptError: Unmanaged? + // guard let decryptedData = SecKeyCreateDecryptedData(keys.privateKey, encryptionAlgorithm, data as CFData, &decryptError) as Data? else { + // throw LocalStorageError.decryptionNotPossible + // } + + return decode(file.readBytes()) + } + + private fun setResourceValues( + alreadyExistedBefore: Boolean, + settings: LocalStorageSetting, + file: File + ) { + try { + if (settings.excludedFromBackupValue) { + // TODO: Check how to exclude files from backup - may need more flexibility here though + } + } catch (error: Throwable) { + // Revert a written file if it did not exist before. + if (!alreadyExistedBefore) { + file.delete() + } + throw LocalStorageError.CouldNotExcludedFromBackup + } + } + + private inline fun file(storageKey: String? = null, type: KClass = C::class): File { + val fileName = storageKey ?: type.qualifiedName ?: throw Error() // TODO: This should never happen, right? + val directory = File(context.filesDir, "edu.stanford.spezi/LocalStorage") + + try { + if (!directory.exists()) + directory.mkdirs() + } catch (error: Throwable) { + println("Failed to create directories: $error") + } + + return File(context.filesDir, "edu.stanford.spezi/LocalStorage/$fileName.localstorage") + } +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt new file mode 100644 index 000000000..463e3e3a7 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt @@ -0,0 +1,13 @@ +package edu.stanford.spezi.modules.storage.local + +sealed class LocalStorageError: Error() { + data object EncryptionNotPossible: LocalStorageError() { + private fun readResolve(): Any = EncryptionNotPossible + } + data object CouldNotExcludedFromBackup: LocalStorageError() { + private fun readResolve(): Any = CouldNotExcludedFromBackup // TODO: Weird naming + } + data object DecryptionNotPossible: LocalStorageError() { + private fun readResolve(): Any = DecryptionNotPossible + } +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt new file mode 100644 index 000000000..085ed9919 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -0,0 +1,58 @@ +package edu.stanford.spezi.modules.storage.local + +import edu.stanford.spezi.modules.storage.secure.SecureStorage +import edu.stanford.spezi.modules.storage.secure.SecureStorageScope +import javax.crypto.SecretKey + +sealed class LocalStorageSetting { // TODO: Adopt android-specific names instead, as SecureEnclave and AccessGroup are iOS-specific + data class Unencrypted( + val excludedFromBackup: Boolean = true + ): LocalStorageSetting() + + data class Encrypted( + val privateKey: SecretKey, + val publicKey: SecretKey, + val excludedFromBackup: Boolean + ): LocalStorageSetting() + + data class EncyptedUsingSecureEnclave( + val userPresence: Boolean = false + ): LocalStorageSetting() + + data class EncryptedUsingKeychain( + val userPresence: Boolean, + val excludedFromBackup: Boolean = true + ): LocalStorageSetting() + + val excludedFromBackupValue: Boolean get() = + when (this) { + is Unencrypted -> excludedFromBackup + is Encrypted -> excludedFromBackup + is EncryptedUsingKeychain -> excludedFromBackup + is EncyptedUsingSecureEnclave -> true + } + + fun keys(secureStorage: SecureStorage): Pair? { + val secureStorageScope = when (this) { + is Unencrypted -> return null + is Encrypted -> return Pair(privateKey, publicKey) + is EncyptedUsingSecureEnclave -> + SecureStorageScope.SecureEnclave(userPresence) + is EncryptedUsingKeychain -> + SecureStorageScope.Keychain(userPresence) + } + + val tag = "LocalStorage.${secureStorageScope.identifier}" + try { + val privateKey = secureStorage.retrievePrivateKey(tag) + val publicKey = secureStorage.retrievePublicKey(tag) + if (privateKey != null && publicKey !== null) + return Pair(privateKey, publicKey) + } catch (_: Throwable) {} + + val privateKey = secureStorage.createKey(tag) + val publicKey = secureStorage.retrievePublicKey(tag) + ?: throw LocalStorageError.EncryptionNotPossible + return Pair(privateKey, publicKey) + } +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt new file mode 100644 index 000000000..aba89e799 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt @@ -0,0 +1,6 @@ +package edu.stanford.spezi.modules.storage.secure + +data class Credentials( + val username: String, + val password: String +) \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt new file mode 100644 index 000000000..3578ff8b6 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt @@ -0,0 +1,88 @@ +package edu.stanford.spezi.modules.storage.secure + +import java.security.KeyStore +import javax.crypto.SecretKey + +class SecureStorage { + private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + fun createKey( + tag: String, + size: Int = 256, + storageScope: SecureStorageScope = SecureStorageScope.secureEnclave + ): SecretKey { + // TODO: Implement + throw NotImplementedError() + } + + fun retrievePrivateKey(tag: String): SecretKey? { + // TODO: Implement + throw NotImplementedError() + } + + fun retrievePublicKey(tag: String): SecretKey? { + // TODO: Implement + throw NotImplementedError() + } + + fun deleteKeys(tag: String) { + // TODO: Implement + throw NotImplementedError() + } + + fun store( + credentials: Credentials, + server: String? = null, + removeDuplicate: Boolean = true, + storageScope: SecureStorageScope + ) { + // TODO: Implement + throw NotImplementedError() + } + + fun deleteCredentials( + username: String, + server: String? = null, + accessGroup: String? = null + ) { + // TODO: Implement + throw NotImplementedError() + } + + fun deleteAllCredentials( + itemTypes: SecureStorageItemTypes = SecureStorageItemTypes.all, + accessGroup: String? = null + ) { + // TODO: Implement + throw NotImplementedError() + } + + fun updateCredentials( + username: String, + server: String? = null, + newCredentials: Credentials, + newServer: String? = null, + removeDuplicate: Boolean = true, + storageScope: SecureStorageScope = SecureStorageScope.keychain + ) { + // TODO: Implement + throw NotImplementedError() + } + + fun retrieveCredentials( + username: String, + server: String? = null, + accessGroup: String? = null + ): Credentials? { + // TODO: Implement + throw NotImplementedError() + } + + fun retrieveAllCredentials( + server: String? = null, + accessGroup: String? = null + ): List { + // TODO: Implement + throw NotImplementedError() + } +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt new file mode 100644 index 000000000..2b6afdaaf --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt @@ -0,0 +1,13 @@ +package edu.stanford.spezi.modules.storage.secure + +sealed class SecureStorageError: Error() { + data object NotFound: SecureStorageError() { + private fun readResolve(): Any = NotFound + } + + data class CreateFailed(val error: Error? = null): SecureStorageError() + data object MissingEntitlement: SecureStorageError() { + private fun readResolve(): Any = MissingEntitlement + } + // TODO: Missing cases for keychainError(status: OSStatus) +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt new file mode 100644 index 000000000..bacd8c32f --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt @@ -0,0 +1,36 @@ +package edu.stanford.spezi.modules.storage.secure + +enum class SecureStorageItemType { + KEYS, + SERVER_CREDENTIALS, + NON_SERVER_CREDENTIALS +} + +data class SecureStorageItemTypes(val types: Set) { + companion object { + val keys = SecureStorageItemTypes( + setOf( + SecureStorageItemType.KEYS + ) + ) + val serverCredentials = SecureStorageItemTypes( + setOf( + SecureStorageItemType.SERVER_CREDENTIALS + ) + ) + val nonServerCredentials = SecureStorageItemTypes( + setOf( + SecureStorageItemType.NON_SERVER_CREDENTIALS + ) + ) + val credentials = SecureStorageItemTypes( + setOf( + SecureStorageItemType.SERVER_CREDENTIALS, + SecureStorageItemType.NON_SERVER_CREDENTIALS + ) + ) + val all = SecureStorageItemTypes( + SecureStorageItemType.entries.toSet() + ) + } +} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt new file mode 100644 index 000000000..a208872dc --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt @@ -0,0 +1,41 @@ +package edu.stanford.spezi.modules.storage.secure + +import android.provider.Settings.Secure + +sealed class SecureStorageScope { + data class SecureEnclave(val userPresence: Boolean = false): SecureStorageScope() + data class Keychain(val userPresence: Boolean = false, val accessGroup: String? = null): SecureStorageScope() + data class KeychainSynchronizable(val accessGroup: String? = null): SecureStorageScope() + + companion object { + val secureEnclave = SecureEnclave() + val keychain = Keychain() + val keychainSynchronizable = KeychainSynchronizable() + } + + val identifier: String get() = + when (this) { + is Keychain -> + "keychain.$userPresence" + (accessGroup?.let { ".$it" } ?: "") + is KeychainSynchronizable -> + "keychainSynchronizable" + (accessGroup?.let { ".$it" } ?: "") + is SecureEnclave -> + "secureEnclave" + } + + val userPresenceValue: Boolean get() = // TODO: Think about removing "Value" suffix + when (this) { + is SecureEnclave -> userPresence + is Keychain -> userPresence + is KeychainSynchronizable -> false + } + + val accessGroupValue: String? get() = + when (this) { + is SecureEnclave -> null + is Keychain -> accessGroup + is KeychainSynchronizable -> accessGroup + } + + // TODO: Missing property accessControl +} \ No newline at end of file From 0ed5bea8c95b7ef3980b83635a9039cf9f41ad6d Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 12:09:28 -0700 Subject: [PATCH 02/18] detekt --- .../modules/storage/local/LocalStorage.kt | 23 +++++++++---------- .../storage/local/LocalStorageError.kt | 10 ++++---- .../storage/local/LocalStorageSetting.kt | 21 +++++++++-------- .../storage/secure/SecureStorageError.kt | 10 ++++---- .../storage/secure/SecureStorageItemTypes.kt | 4 ++-- .../storage/secure/SecureStorageScope.kt | 10 ++++---- 6 files changed, 38 insertions(+), 40 deletions(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index 3651285ce..e5913efcf 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -1,7 +1,6 @@ package edu.stanford.spezi.modules.storage.local import android.content.Context -import androidx.security.crypto.EncryptedFile import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import edu.stanford.spezi.core.coroutines.di.Dispatching @@ -14,19 +13,19 @@ import kotlin.reflect.KClass class LocalStorage @Inject constructor( @ApplicationContext val context: Context, @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, - ) { +) { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val secureStorage = SecureStorage() - private inline fun store( + private inline fun store( // TODO: iOS has this only as a private helper function element: C, storageKey: String?, settings: LocalStorageSetting, - encode: (C) -> ByteArray - ): Unit { + encode: (C) -> ByteArray, + ) { val file = file(storageKey, C::class) val alreadyExistedBefore = file.exists() @@ -34,10 +33,9 @@ class LocalStorage @Inject constructor( // Called at the end of each execution path // We can not use defer as the function can potentially throw an error. - val data = encode(element) - val keys = settings.keys(secureStorage) + val keys = settings.keys(secureStorage) // Determine if the data should be encrypted or not: if (keys == null) { @@ -67,10 +65,10 @@ class LocalStorage @Inject constructor( setResourceValues(alreadyExistedBefore, settings, file) } - private inline fun read( // TODO: iOS only has this as a private helper + private inline fun read( // TODO: iOS only has this as a private helper storageKey: String?, settings: LocalStorageSetting, - decode: (ByteArray) -> C + decode: (ByteArray) -> C, ): C { val file = file(storageKey, C::class) val keys = settings.keys(secureStorage = secureStorage) @@ -95,7 +93,7 @@ class LocalStorage @Inject constructor( private fun setResourceValues( alreadyExistedBefore: Boolean, settings: LocalStorageSetting, - file: File + file: File, ) { try { if (settings.excludedFromBackupValue) { @@ -115,12 +113,13 @@ class LocalStorage @Inject constructor( val directory = File(context.filesDir, "edu.stanford.spezi/LocalStorage") try { - if (!directory.exists()) + if (!directory.exists()) { directory.mkdirs() + } } catch (error: Throwable) { println("Failed to create directories: $error") } return File(context.filesDir, "edu.stanford.spezi/LocalStorage/$fileName.localstorage") } -} \ No newline at end of file +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt index 463e3e3a7..1ee977d80 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt @@ -1,13 +1,13 @@ package edu.stanford.spezi.modules.storage.local -sealed class LocalStorageError: Error() { - data object EncryptionNotPossible: LocalStorageError() { +sealed class LocalStorageError : Error() { + data object EncryptionNotPossible : LocalStorageError() { private fun readResolve(): Any = EncryptionNotPossible } - data object CouldNotExcludedFromBackup: LocalStorageError() { + data object CouldNotExcludedFromBackup : LocalStorageError() { private fun readResolve(): Any = CouldNotExcludedFromBackup // TODO: Weird naming } - data object DecryptionNotPossible: LocalStorageError() { + data object DecryptionNotPossible : LocalStorageError() { private fun readResolve(): Any = DecryptionNotPossible } -} \ No newline at end of file +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt index 085ed9919..dc3e50135 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -6,23 +6,23 @@ import javax.crypto.SecretKey sealed class LocalStorageSetting { // TODO: Adopt android-specific names instead, as SecureEnclave and AccessGroup are iOS-specific data class Unencrypted( - val excludedFromBackup: Boolean = true - ): LocalStorageSetting() + val excludedFromBackup: Boolean = true, + ) : LocalStorageSetting() data class Encrypted( val privateKey: SecretKey, val publicKey: SecretKey, - val excludedFromBackup: Boolean - ): LocalStorageSetting() + val excludedFromBackup: Boolean, + ) : LocalStorageSetting() data class EncyptedUsingSecureEnclave( - val userPresence: Boolean = false - ): LocalStorageSetting() + val userPresence: Boolean = false, + ) : LocalStorageSetting() data class EncryptedUsingKeychain( val userPresence: Boolean, - val excludedFromBackup: Boolean = true - ): LocalStorageSetting() + val excludedFromBackup: Boolean = true, + ) : LocalStorageSetting() val excludedFromBackupValue: Boolean get() = when (this) { @@ -46,8 +46,9 @@ sealed class LocalStorageSetting { // TODO: Adopt android-specific names instead try { val privateKey = secureStorage.retrievePrivateKey(tag) val publicKey = secureStorage.retrievePublicKey(tag) - if (privateKey != null && publicKey !== null) + if (privateKey != null && publicKey !== null) { return Pair(privateKey, publicKey) + } } catch (_: Throwable) {} val privateKey = secureStorage.createKey(tag) @@ -55,4 +56,4 @@ sealed class LocalStorageSetting { // TODO: Adopt android-specific names instead ?: throw LocalStorageError.EncryptionNotPossible return Pair(privateKey, publicKey) } -} \ No newline at end of file +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt index 2b6afdaaf..203786611 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt @@ -1,13 +1,13 @@ package edu.stanford.spezi.modules.storage.secure -sealed class SecureStorageError: Error() { - data object NotFound: SecureStorageError() { +sealed class SecureStorageError : Error() { + data object NotFound : SecureStorageError() { private fun readResolve(): Any = NotFound } - data class CreateFailed(val error: Error? = null): SecureStorageError() - data object MissingEntitlement: SecureStorageError() { + data class CreateFailed(val error: Error? = null) : SecureStorageError() + data object MissingEntitlement : SecureStorageError() { private fun readResolve(): Any = MissingEntitlement } // TODO: Missing cases for keychainError(status: OSStatus) -} \ No newline at end of file +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt index bacd8c32f..c45895a92 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt @@ -3,7 +3,7 @@ package edu.stanford.spezi.modules.storage.secure enum class SecureStorageItemType { KEYS, SERVER_CREDENTIALS, - NON_SERVER_CREDENTIALS + NON_SERVER_CREDENTIALS, } data class SecureStorageItemTypes(val types: Set) { @@ -33,4 +33,4 @@ data class SecureStorageItemTypes(val types: Set) { SecureStorageItemType.entries.toSet() ) } -} \ No newline at end of file +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt index a208872dc..3a718d23f 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt @@ -1,11 +1,9 @@ package edu.stanford.spezi.modules.storage.secure -import android.provider.Settings.Secure - sealed class SecureStorageScope { - data class SecureEnclave(val userPresence: Boolean = false): SecureStorageScope() - data class Keychain(val userPresence: Boolean = false, val accessGroup: String? = null): SecureStorageScope() - data class KeychainSynchronizable(val accessGroup: String? = null): SecureStorageScope() + data class SecureEnclave(val userPresence: Boolean = false) : SecureStorageScope() + data class Keychain(val userPresence: Boolean = false, val accessGroup: String? = null) : SecureStorageScope() + data class KeychainSynchronizable(val accessGroup: String? = null) : SecureStorageScope() companion object { val secureEnclave = SecureEnclave() @@ -38,4 +36,4 @@ sealed class SecureStorageScope { } // TODO: Missing property accessControl -} \ No newline at end of file +} From a7dc44d2bdde937258ed7019d5a84941e948574e Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 21:32:31 -0700 Subject: [PATCH 03/18] Finish up SpeziStorage --- .../modules/storage/LocalStorageTests.kt | 41 +++++ .../modules/storage/SecureStorageTests.kt | 159 ++++++++++++++++++ .../storage/local/LocalStorageTests.kt | 26 --- .../modules/storage/local/LocalStorage.kt | 116 +++++-------- .../storage/local/LocalStorageError.kt | 3 + .../storage/local/LocalStorageSetting.kt | 51 ++---- .../modules/storage/secure/SecureStorage.kt | 129 +++++++++----- .../storage/secure/SecureStorageScope.kt | 33 +--- 8 files changed, 352 insertions(+), 206 deletions(-) create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt delete mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt new file mode 100644 index 000000000..02b3881b7 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt @@ -0,0 +1,41 @@ +package edu.stanford.spezi.modules.storage + +import androidx.test.platform.app.InstrumentationRegistry +import edu.stanford.spezi.modules.storage.local.LocalStorage +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting +import org.junit.Test +import java.io.Serializable +import java.nio.charset.StandardCharsets +import kotlin.random.Random + +class LocalStorageTests { + data class Letter(val greeting: String) + + @Test + fun localStorage() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val localStorage = LocalStorage(context) + + var greeting = "Hello Paul 👋" + for (index in 0..Random.nextInt(10)) { + greeting += "🚀" + } + val letter = Letter(greeting = greeting) + localStorage.store( + letter, + type = Letter::class, + settings = LocalStorageSetting.Unencrypted, + encode = { letter.greeting.toByteArray(StandardCharsets.UTF_8) } + ) + val storedLetter: Letter = localStorage.read( + settings = LocalStorageSetting.Unencrypted, + type = Letter::class, + decode = { Letter(it.toString(StandardCharsets.UTF_8)) } + ) + + assert(letter.greeting == storedLetter.greeting) + + localStorage.delete(Letter::class) + localStorage.delete(storageKey = "Letter") + } +} \ No newline at end of file diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt new file mode 100644 index 000000000..9c0bd6c5c --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt @@ -0,0 +1,159 @@ +package edu.stanford.spezi.modules.storage + +import androidx.test.platform.app.InstrumentationRegistry +import edu.stanford.spezi.modules.storage.secure.Credentials +import edu.stanford.spezi.modules.storage.secure.SecureStorage +import edu.stanford.spezi.modules.storage.secure.SecureStorageItemTypes +import org.junit.Test +import java.nio.charset.StandardCharsets +import javax.crypto.Cipher + + +class SecureStorageTests { + + private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val secureStorage = SecureStorage(targetContext) + + @Test + fun testDeleteAllCredentials() { + val serverCredentials1 = Credentials("@Schmiedmayer", "SpeziInventor") + secureStorage.store(serverCredentials1, "apple.com") + + val serverCredentials2 = Credentials("Stanford Spezi", "Paul") + secureStorage.store(serverCredentials2) + + secureStorage.createKey("DeleteKeyTest") + secureStorage.deleteAllCredentials(SecureStorageItemTypes.all) + + assert(secureStorage.retrieveAllCredentials("apple.com").isEmpty()) + assert(secureStorage.retrieveAllCredentials().isEmpty()) + assert(secureStorage.retrievePrivateKey("DeleteKeyTest") == null) + assert(secureStorage.retrievePublicKey("DeleteKeyTest") == null) + } + + @Test + fun testCredentials() { + secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) + + val serverCredentials0 = Credentials("@PSchmiedmayer", "SpeziInventor") + secureStorage.store(serverCredentials0) + secureStorage.store(serverCredentials0) // Overwrite existing credentials + + val retrievedCredentials0 = secureStorage.retrieveCredentials("@PSchmiedmayer") + assert(serverCredentials0.username == retrievedCredentials0?.username) + assert(serverCredentials0.password == retrievedCredentials0?.password) + + val serverCredentials1 = Credentials("@Spezi", "Paul") + secureStorage.updateCredentials("@PSchmiedmayer", newCredentials = serverCredentials1) + + val retrievedCredentials1 = secureStorage.retrieveCredentials("@Spezi") + assert(serverCredentials1.username == retrievedCredentials1?.username) + assert(serverCredentials1.password == retrievedCredentials1?.password) + + secureStorage.deleteCredentials("@Spezi") + assert(secureStorage.retrieveCredentials("@Spezi") == null) + } + + @Test + fun testInternetCredentials() { + secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) + + val credentials0 = Credentials("@PSchmiedmayer", "SpeziInventor") + secureStorage.store(credentials0, server = "twitter.com") + secureStorage.store(credentials0, server = "twitter.com") // Overwrite existing credentials. + + val retrievedCredentials0 = + secureStorage.retrieveCredentials("@PSchmiedmayer", "twitter.com") + assert(credentials0.username == retrievedCredentials0?.username) + assert(credentials0.password == retrievedCredentials0?.password) + + val credentials1 = Credentials("@Spezi", "Paul") + secureStorage.updateCredentials( + "@PSchmiedmayer", + server = "twitter.com", + newCredentials = credentials1, + newServer = "stanford.edu" + ) + + val retrievedCredentials1 = secureStorage.retrieveCredentials("@Spezi", "stanford.edu") + assert(credentials1.username == retrievedCredentials1?.username) + assert(credentials1.password == retrievedCredentials1?.password) + + secureStorage.deleteCredentials("@Spezi", "stanford.edu") + assert(secureStorage.retrieveCredentials("@Spezi", "stanford.edu") == null) + } + + @Test + fun testMultipleInternetCredentials() { + secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) + + val credentials0 = Credentials("Paul Schmiedmayer", "SpeziInventor") + secureStorage.store(credentials0, "linkedin.com") + + val credentials1 = Credentials("Stanford Spezi", "Paul") + secureStorage.store(credentials1, "linkedin.com") + + val retrievedCredentials = secureStorage.retrieveAllCredentials(server = "linkedin.com") + assert(retrievedCredentials.size == 2) + + assert(retrievedCredentials.firstOrNull { it.username == credentials0.username }?.password == credentials0.password) + assert(retrievedCredentials.firstOrNull { it.username == credentials1.username }?.password == credentials1.password) + + secureStorage.deleteCredentials("Paul Schmiedmayer", server = "linkedin.com") + secureStorage.deleteCredentials("Stanford Spezi", server = "linkedin.com") + + val retrievedCredentialsEmpty = secureStorage.retrieveAllCredentials("linkedin.com") + assert(retrievedCredentialsEmpty.isEmpty()) + } + + @Test + fun testMultipleCredentials() { + secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) + + val credentials0 = Credentials("Paul Schmiedmayer", "SpeziInventor") + secureStorage.store(credentials0) + + val credentials1 = Credentials("Stanford Spezi", "Paul") + secureStorage.store(credentials1) + + val retrievedCredentials = secureStorage.retrieveAllCredentials() + assert(retrievedCredentials.size == 2) + + assert(retrievedCredentials.firstOrNull { it.username == credentials0.username }?.password == credentials0.password) + assert(retrievedCredentials.firstOrNull { it.username == credentials1.username }?.password == credentials1.password) + + secureStorage.deleteCredentials("Paul Schmiedmayer") + secureStorage.deleteCredentials("Stanford Spezi") + + val retrievedCredentialsEmpty = secureStorage.retrieveAllCredentials() + assert(retrievedCredentialsEmpty.isEmpty()) + } + + @Test + fun testKeys() { + secureStorage.deleteAllCredentials(SecureStorageItemTypes.keys) + assert(secureStorage.retrievePublicKey("MyKey") == null) + + val keyPair = secureStorage.createKey("MyKey") + + val privateKey = keyPair.private + assert(secureStorage.retrievePrivateKey("MyKey") == privateKey) + + val publicKey = keyPair.public + assert(secureStorage.retrievePublicKey("MyKey") == publicKey) + + val plainText = "Spezi & Paul Schmiedmayer".toByteArray(StandardCharsets.UTF_8) + println(plainText.toString(StandardCharsets.UTF_8)) + + val encipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + encipher.init(Cipher.ENCRYPT_MODE, publicKey) + val encryptedBytes = encipher.doFinal(plainText) + + val decipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + decipher.init(Cipher.DECRYPT_MODE, privateKey) + val decryptedBytes = decipher.doFinal(encryptedBytes) + println(decryptedBytes.toString(StandardCharsets.UTF_8)) + + assert(decryptedBytes.contentEquals(plainText)) + } +} \ No newline at end of file diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt deleted file mode 100644 index ca23260d0..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt +++ /dev/null @@ -1,26 +0,0 @@ -package edu.stanford.spezi.modules.storage.local - -import org.junit.Test -import kotlin.random.Random - -class LocalStorageTests { - data class Letter(val greeting: String) - - @Test - fun localStorage() { - val localStorage = LocalStorage() - - var greeting = "Hello Paul 👋" - for (index in 0..Random.nextInt(10)) { - greeting += "🚀" - } - val letter = Letter(greeting = greeting) - localStorage.store(letter, settings = LocalStorageSetting.Unencrypted) - val storedLetter: Letter = localStorage.read(settings = LocalStorageSetting.Unencrypted) - - assert(letter.greeting == storedLetter.greeting) - - localStorage.delete(Letter::class) - localStorage.delete(storageKey = "Letter") - } -} \ No newline at end of file diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index e5913efcf..42ddd2951 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -1,115 +1,83 @@ package edu.stanford.spezi.modules.storage.local import android.content.Context -import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext -import edu.stanford.spezi.core.coroutines.di.Dispatching import edu.stanford.spezi.modules.storage.secure.SecureStorage -import kotlinx.coroutines.CoroutineDispatcher import java.io.File +import java.io.Serializable +import java.security.Key +import javax.crypto.Cipher import javax.inject.Inject import kotlin.reflect.KClass class LocalStorage @Inject constructor( @ApplicationContext val context: Context, - @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, ) { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - private val secureStorage = SecureStorage() + private val secureStorage = SecureStorage(context) - private inline fun store( - // TODO: iOS has this only as a private helper function + private fun createCipher(mode: Int, key: Key): Cipher = + // TODO: Supported values: https://developer.android.com/reference/kotlin/javax/crypto/Cipher + Cipher.getInstance("RSA/ECB/PKCS1Padding").apply { init(mode, key) } + + fun store( element: C, - storageKey: String?, + storageKey: String? = null, + type: KClass, settings: LocalStorageSetting, encode: (C) -> ByteArray, ) { - val file = file(storageKey, C::class) - - val alreadyExistedBefore = file.exists() - - // Called at the end of each execution path - // We can not use defer as the function can potentially throw an error. - + val file = file(storageKey, type) val data = encode(element) - - val keys = settings.keys(secureStorage) - - // Determine if the data should be encrypted or not: - if (keys == null) { + val keys = settings.keys(secureStorage) ?: run { file.writeBytes(data) - setResourceValues(alreadyExistedBefore, settings, file) return } - - // TODO: Check if encryption is supported - // - // iOS: - // // Encryption enabled: - // guard SecKeyIsAlgorithmSupported (keys.publicKey, .encrypt, encryptionAlgorithm) else { - // throw LocalStorageError.encryptionNotPossible - // } - // - // var encryptError: Unmanaged? - // guard let encryptedData = SecKeyCreateEncryptedData( - // keys.publicKey, - // encryptionAlgorithm, - // data as CFData, & encryptError) as Data? else { - // throw LocalStorageError.encryptionNotPossible - // } - - val encryptedData = data + val encryptedData = createCipher(Cipher.ENCRYPT_MODE, keys.public) + .doFinal(data) file.writeBytes(encryptedData) - setResourceValues(alreadyExistedBefore, settings, file) } - private inline fun read( // TODO: iOS only has this as a private helper - storageKey: String?, + fun read( + storageKey: String? = null, + type: KClass, settings: LocalStorageSetting, decode: (ByteArray) -> C, ): C { - val file = file(storageKey, C::class) + val file = file(storageKey, type) val keys = settings.keys(secureStorage = secureStorage) ?: return decode(file.readBytes()) + val data = createCipher(Cipher.DECRYPT_MODE, keys.private) + .doFinal(file.readBytes()) + return decode(data) + } - val privateKey = keys.first - val publicKey = keys.second - - // TODO: iOS decryption: - // guard SecKeyIsAlgorithmSupported(keys.privateKey, .decrypt, encryptionAlgorithm) else { - // throw LocalStorageError.decryptionNotPossible - // } - - // var decryptError: Unmanaged? - // guard let decryptedData = SecKeyCreateDecryptedData(keys.privateKey, encryptionAlgorithm, data as CFData, &decryptError) as Data? else { - // throw LocalStorageError.decryptionNotPossible - // } + fun delete(storageKey: String?) { + delete(storageKey, String::class) + } - return decode(file.readBytes()) + fun delete(type: KClass) { + delete(null, type) } - private fun setResourceValues( - alreadyExistedBefore: Boolean, - settings: LocalStorageSetting, - file: File, + private fun delete( + storageKey: String?, + type: KClass, ) { - try { - if (settings.excludedFromBackupValue) { - // TODO: Check how to exclude files from backup - may need more flexibility here though - } - } catch (error: Throwable) { - // Revert a written file if it did not exist before. - if (!alreadyExistedBefore) { + val file = file(storageKey, type) + if (file.exists()) { + try { file.delete() + } catch (error: Throwable) { + throw LocalStorageError.DeletionNotPossible } - throw LocalStorageError.CouldNotExcludedFromBackup } } - private inline fun file(storageKey: String? = null, type: KClass = C::class): File { - val fileName = storageKey ?: type.qualifiedName ?: throw Error() // TODO: This should never happen, right? + private fun file(storageKey: String?, type: KClass<*>): File { + val filename = storageKey + ?: type.qualifiedName + ?: type.simpleName + ?: throw Error() // TODO: Figure out what to do here?! val directory = File(context.filesDir, "edu.stanford.spezi/LocalStorage") try { @@ -120,6 +88,6 @@ class LocalStorage @Inject constructor( println("Failed to create directories: $error") } - return File(context.filesDir, "edu.stanford.spezi/LocalStorage/$fileName.localstorage") + return File(context.filesDir, "edu.stanford.spezi/LocalStorage/$filename.localstorage") } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt index 1ee977d80..29dbc730f 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt @@ -10,4 +10,7 @@ sealed class LocalStorageError : Error() { data object DecryptionNotPossible : LocalStorageError() { private fun readResolve(): Any = DecryptionNotPossible } + data object DeletionNotPossible : LocalStorageError() { + private fun readResolve(): Any = DeletionNotPossible + } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt index dc3e50135..a5381c297 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -2,58 +2,33 @@ package edu.stanford.spezi.modules.storage.local import edu.stanford.spezi.modules.storage.secure.SecureStorage import edu.stanford.spezi.modules.storage.secure.SecureStorageScope -import javax.crypto.SecretKey +import java.security.KeyPair -sealed class LocalStorageSetting { // TODO: Adopt android-specific names instead, as SecureEnclave and AccessGroup are iOS-specific - data class Unencrypted( - val excludedFromBackup: Boolean = true, - ) : LocalStorageSetting() +sealed class LocalStorageSetting { + data object Unencrypted : LocalStorageSetting() - data class Encrypted( - val privateKey: SecretKey, - val publicKey: SecretKey, - val excludedFromBackup: Boolean, - ) : LocalStorageSetting() + data class Encrypted(val keyPair: KeyPair) : LocalStorageSetting() - data class EncyptedUsingSecureEnclave( - val userPresence: Boolean = false, - ) : LocalStorageSetting() + data object EncryptedUsingKeyStore : LocalStorageSetting() - data class EncryptedUsingKeychain( - val userPresence: Boolean, - val excludedFromBackup: Boolean = true, - ) : LocalStorageSetting() - - val excludedFromBackupValue: Boolean get() = - when (this) { - is Unencrypted -> excludedFromBackup - is Encrypted -> excludedFromBackup - is EncryptedUsingKeychain -> excludedFromBackup - is EncyptedUsingSecureEnclave -> true - } - - fun keys(secureStorage: SecureStorage): Pair? { + @Suppress("detekt:ReturnCount") + fun keys(secureStorage: SecureStorage): KeyPair? { val secureStorageScope = when (this) { is Unencrypted -> return null - is Encrypted -> return Pair(privateKey, publicKey) - is EncyptedUsingSecureEnclave -> - SecureStorageScope.SecureEnclave(userPresence) - is EncryptedUsingKeychain -> - SecureStorageScope.Keychain(userPresence) + is Encrypted -> return keyPair + is EncryptedUsingKeyStore -> + SecureStorageScope.KeyStore } val tag = "LocalStorage.${secureStorageScope.identifier}" try { val privateKey = secureStorage.retrievePrivateKey(tag) val publicKey = secureStorage.retrievePublicKey(tag) - if (privateKey != null && publicKey !== null) { - return Pair(privateKey, publicKey) + if (privateKey != null && publicKey != null) { + return KeyPair(publicKey, privateKey) } } catch (_: Throwable) {} - val privateKey = secureStorage.createKey(tag) - val publicKey = secureStorage.retrievePublicKey(tag) - ?: throw LocalStorageError.EncryptionNotPossible - return Pair(privateKey, publicKey) + return secureStorage.createKey(tag) } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt index 3578ff8b6..e03e8734f 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt @@ -1,60 +1,95 @@ package edu.stanford.spezi.modules.storage.secure +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.KeyPair +import java.security.KeyPairGenerator import java.security.KeyStore -import javax.crypto.SecretKey +import java.security.PrivateKey +import java.security.PublicKey -class SecureStorage { - private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } +class SecureStorage( + @ApplicationContext val context: Context, +) { + private val provider = "AndroidKeyStore" + private val keyStore: KeyStore = KeyStore.getInstance(provider).apply { load(null) } + private val preferences: SharedPreferences = context.getSharedPreferences("Spezi_SecureStoragePrefs", Context.MODE_PRIVATE) fun createKey( tag: String, - size: Int = 256, - storageScope: SecureStorageScope = SecureStorageScope.secureEnclave - ): SecretKey { - // TODO: Implement - throw NotImplementedError() + size: Int = 2048, // TODO: Should we just use RSA here instead of what iOS uses? + ): KeyPair { + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + tag, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setKeySize(size) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .build() + val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA) + keyPairGenerator.initialize(keyGenParameterSpec) + return keyPairGenerator.genKeyPair() } - fun retrievePrivateKey(tag: String): SecretKey? { - // TODO: Implement - throw NotImplementedError() + fun retrievePrivateKey(tag: String): PrivateKey? { + return keyStore.getKey(tag, null) as? PrivateKey } - fun retrievePublicKey(tag: String): SecretKey? { - // TODO: Implement - throw NotImplementedError() + fun retrievePublicKey(tag: String): PublicKey? { + keyStore.getKey(tag, null) as? PrivateKey + return keyStore.getCertificate(tag)?.publicKey } fun deleteKeys(tag: String) { - // TODO: Implement - throw NotImplementedError() + keyStore.deleteEntry(tag) } fun store( credentials: Credentials, server: String? = null, - removeDuplicate: Boolean = true, - storageScope: SecureStorageScope ) { - // TODO: Implement - throw NotImplementedError() + preferences.edit { + putString(sharedPreferencesKey(server, credentials.username), credentials.password) + } } fun deleteCredentials( username: String, server: String? = null, - accessGroup: String? = null ) { - // TODO: Implement - throw NotImplementedError() + val key = sharedPreferencesKey(server, username) + preferences.edit { remove(key) } } - fun deleteAllCredentials( - itemTypes: SecureStorageItemTypes = SecureStorageItemTypes.all, - accessGroup: String? = null - ) { - // TODO: Implement - throw NotImplementedError() + fun deleteAllCredentials(itemTypes: SecureStorageItemTypes) { + val containsServerCredentials = itemTypes.types.contains(SecureStorageItemType.SERVER_CREDENTIALS) + val containsNonServerCredentials = itemTypes.types.contains(SecureStorageItemType.NON_SERVER_CREDENTIALS) + if (containsServerCredentials || containsNonServerCredentials) { + preferences.edit { + preferences.all.forEach { + if (it.key.startsWith(" ")) { // non-server credential + if (containsNonServerCredentials) { + remove(it.key) + } + } else { + if (containsServerCredentials) { + remove(it.key) + } + } + } + } + } + + if (itemTypes.types.contains(SecureStorageItemType.KEYS)) { + for (tag in keyStore.aliases()) { + keyStore.deleteEntry(tag) + } + } } fun updateCredentials( @@ -62,27 +97,41 @@ class SecureStorage { server: String? = null, newCredentials: Credentials, newServer: String? = null, - removeDuplicate: Boolean = true, - storageScope: SecureStorageScope = SecureStorageScope.keychain ) { - // TODO: Implement - throw NotImplementedError() + deleteCredentials(username, server) + store(newCredentials, newServer) } fun retrieveCredentials( username: String, server: String? = null, - accessGroup: String? = null ): Credentials? { - // TODO: Implement - throw NotImplementedError() + val key = sharedPreferencesKey(server, username) + return preferences.getString(key, null)?.let { + Credentials(username, it) + } } fun retrieveAllCredentials( server: String? = null, - accessGroup: String? = null ): List { - // TODO: Implement - throw NotImplementedError() + return preferences.all.mapNotNull { entry -> + val password = server?.let { + if (entry.key.startsWith("$server ")) { + entry.value as? String + } else { + null + } + } ?: entry.value as? String + + password?.let { + val separatorIndex = entry.key.indexOf(" ") + Credentials(entry.key.drop(separatorIndex + 1), password) + } + } } -} \ No newline at end of file + + // TODO: Check for potential key collisions + private fun sharedPreferencesKey(server: String?, username: String): String = + "${server ?: ""} $username" +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt index 3a718d23f..923980e3d 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt @@ -1,39 +1,16 @@ package edu.stanford.spezi.modules.storage.secure sealed class SecureStorageScope { - data class SecureEnclave(val userPresence: Boolean = false) : SecureStorageScope() - data class Keychain(val userPresence: Boolean = false, val accessGroup: String? = null) : SecureStorageScope() - data class KeychainSynchronizable(val accessGroup: String? = null) : SecureStorageScope() - - companion object { - val secureEnclave = SecureEnclave() - val keychain = Keychain() - val keychainSynchronizable = KeychainSynchronizable() - } + data object KeyStore : SecureStorageScope() val identifier: String get() = when (this) { - is Keychain -> - "keychain.$userPresence" + (accessGroup?.let { ".$it" } ?: "") - is KeychainSynchronizable -> - "keychainSynchronizable" + (accessGroup?.let { ".$it" } ?: "") - is SecureEnclave -> - "secureEnclave" + is KeyStore -> + "keyStore" } - val userPresenceValue: Boolean get() = // TODO: Think about removing "Value" suffix + val userPresence: Boolean get() = when (this) { - is SecureEnclave -> userPresence - is Keychain -> userPresence - is KeychainSynchronizable -> false + is KeyStore -> false } - - val accessGroupValue: String? get() = - when (this) { - is SecureEnclave -> null - is Keychain -> accessGroup - is KeychainSynchronizable -> accessGroup - } - - // TODO: Missing property accessControl } From 86da8afd3505efcb7eedc1497f2a7cd0bf27c4b1 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 21:42:46 -0700 Subject: [PATCH 04/18] detekt --- .../modules/storage/LocalStorageTests.kt | 3 +-- .../modules/storage/SecureStorageTests.kt | 5 ++--- .../modules/storage/local/LocalStorage.kt | 19 ++++--------------- .../storage/local/LocalStorageError.kt | 16 ++++------------ .../storage/secure/SecureStorageError.kt | 13 ------------- .../storage/secure/SecureStorageScope.kt | 5 ----- 6 files changed, 11 insertions(+), 50 deletions(-) delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt index 02b3881b7..faa00b537 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt @@ -4,7 +4,6 @@ import androidx.test.platform.app.InstrumentationRegistry import edu.stanford.spezi.modules.storage.local.LocalStorage import edu.stanford.spezi.modules.storage.local.LocalStorageSetting import org.junit.Test -import java.io.Serializable import java.nio.charset.StandardCharsets import kotlin.random.Random @@ -38,4 +37,4 @@ class LocalStorageTests { localStorage.delete(Letter::class) localStorage.delete(storageKey = "Letter") } -} \ No newline at end of file +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt index 9c0bd6c5c..bcdceb268 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt @@ -8,11 +8,10 @@ import org.junit.Test import java.nio.charset.StandardCharsets import javax.crypto.Cipher - class SecureStorageTests { private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext - val secureStorage = SecureStorage(targetContext) + private val secureStorage = SecureStorage(targetContext) @Test fun testDeleteAllCredentials() { @@ -156,4 +155,4 @@ class SecureStorageTests { assert(decryptedBytes.contentEquals(plainText)) } -} \ No newline at end of file +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index 42ddd2951..338e2a6db 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -4,7 +4,6 @@ import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import edu.stanford.spezi.modules.storage.secure.SecureStorage import java.io.File -import java.io.Serializable import java.security.Key import javax.crypto.Cipher import javax.inject.Inject @@ -65,11 +64,7 @@ class LocalStorage @Inject constructor( ) { val file = file(storageKey, type) if (file.exists()) { - try { - file.delete() - } catch (error: Throwable) { - throw LocalStorageError.DeletionNotPossible - } + file.delete() } } @@ -77,17 +72,11 @@ class LocalStorage @Inject constructor( val filename = storageKey ?: type.qualifiedName ?: type.simpleName - ?: throw Error() // TODO: Figure out what to do here?! + ?: LocalStorageError.FileNameCouldNotBeIdentified val directory = File(context.filesDir, "edu.stanford.spezi/LocalStorage") - - try { - if (!directory.exists()) { - directory.mkdirs() - } - } catch (error: Throwable) { - println("Failed to create directories: $error") + if (!directory.exists()) { + directory.mkdirs() } - return File(context.filesDir, "edu.stanford.spezi/LocalStorage/$filename.localstorage") } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt index 29dbc730f..d161083a6 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt @@ -1,16 +1,8 @@ package edu.stanford.spezi.modules.storage.local -sealed class LocalStorageError : Error() { - data object EncryptionNotPossible : LocalStorageError() { - private fun readResolve(): Any = EncryptionNotPossible - } - data object CouldNotExcludedFromBackup : LocalStorageError() { - private fun readResolve(): Any = CouldNotExcludedFromBackup // TODO: Weird naming - } - data object DecryptionNotPossible : LocalStorageError() { - private fun readResolve(): Any = DecryptionNotPossible - } - data object DeletionNotPossible : LocalStorageError() { - private fun readResolve(): Any = DeletionNotPossible +sealed class LocalStorageError : Exception() { + data object FileNameCouldNotBeIdentified : LocalStorageError() { + @Suppress("detekt:UnusedPrivateMember") + private fun readResolve(): Any = FileNameCouldNotBeIdentified } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt deleted file mode 100644 index 203786611..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageError.kt +++ /dev/null @@ -1,13 +0,0 @@ -package edu.stanford.spezi.modules.storage.secure - -sealed class SecureStorageError : Error() { - data object NotFound : SecureStorageError() { - private fun readResolve(): Any = NotFound - } - - data class CreateFailed(val error: Error? = null) : SecureStorageError() - data object MissingEntitlement : SecureStorageError() { - private fun readResolve(): Any = MissingEntitlement - } - // TODO: Missing cases for keychainError(status: OSStatus) -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt index 923980e3d..efc8d5a2d 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt @@ -8,9 +8,4 @@ sealed class SecureStorageScope { is KeyStore -> "keyStore" } - - val userPresence: Boolean get() = - when (this) { - is KeyStore -> false - } } From 953073c95232599ff46829c53fc437c9a17b703d Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 21:44:11 -0700 Subject: [PATCH 05/18] Remove unused code --- .../edu/stanford/spezi/modules/storage/secure/SecureStorage.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt index e03e8734f..e1c3797f6 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt @@ -41,7 +41,6 @@ class SecureStorage( } fun retrievePublicKey(tag: String): PublicKey? { - keyStore.getKey(tag, null) as? PrivateKey return keyStore.getCertificate(tag)?.publicKey } From 01125f72f76ead07b661722cc53a882a9e823944 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 21:45:53 -0700 Subject: [PATCH 06/18] detekt --- .../edu/stanford/spezi/modules/storage/secure/Credentials.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt index aba89e799..104e1726c 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt @@ -2,5 +2,5 @@ package edu.stanford.spezi.modules.storage.secure data class Credentials( val username: String, - val password: String -) \ No newline at end of file + val password: String, +) From 3e35f372453c4c27c2f9b0e299f9409ea705fdf4 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 21:59:10 -0700 Subject: [PATCH 07/18] Use OAEPPadding --- .../edu/stanford/spezi/modules/storage/SecureStorageTests.kt | 4 ++-- .../edu/stanford/spezi/modules/storage/local/LocalStorage.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt index bcdceb268..4dd9d625f 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt @@ -144,11 +144,11 @@ class SecureStorageTests { val plainText = "Spezi & Paul Schmiedmayer".toByteArray(StandardCharsets.UTF_8) println(plainText.toString(StandardCharsets.UTF_8)) - val encipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + val encipher = Cipher.getInstance("RSA/ECB/OAEPPadding") encipher.init(Cipher.ENCRYPT_MODE, publicKey) val encryptedBytes = encipher.doFinal(plainText) - val decipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + val decipher = Cipher.getInstance("RSA/ECB/OAEPPadding") decipher.init(Cipher.DECRYPT_MODE, privateKey) val decryptedBytes = decipher.doFinal(encryptedBytes) println(decryptedBytes.toString(StandardCharsets.UTF_8)) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index 338e2a6db..f55139353 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -16,7 +16,7 @@ class LocalStorage @Inject constructor( private fun createCipher(mode: Int, key: Key): Cipher = // TODO: Supported values: https://developer.android.com/reference/kotlin/javax/crypto/Cipher - Cipher.getInstance("RSA/ECB/PKCS1Padding").apply { init(mode, key) } + Cipher.getInstance("RSA/ECB/OAEPPadding").apply { init(mode, key) } fun store( element: C, From 072b5d2da3f53fba0b9e558763db2bb66593e7fc Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 22:01:51 -0700 Subject: [PATCH 08/18] Use other padding --- .../edu/stanford/spezi/modules/storage/SecureStorageTests.kt | 4 ++-- .../edu/stanford/spezi/modules/storage/local/LocalStorage.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt index 4dd9d625f..fef6af54a 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt @@ -144,11 +144,11 @@ class SecureStorageTests { val plainText = "Spezi & Paul Schmiedmayer".toByteArray(StandardCharsets.UTF_8) println(plainText.toString(StandardCharsets.UTF_8)) - val encipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + val encipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") encipher.init(Cipher.ENCRYPT_MODE, publicKey) val encryptedBytes = encipher.doFinal(plainText) - val decipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + val decipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") decipher.init(Cipher.DECRYPT_MODE, privateKey) val decryptedBytes = decipher.doFinal(encryptedBytes) println(decryptedBytes.toString(StandardCharsets.UTF_8)) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index f55139353..4fd92fc2e 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -16,7 +16,7 @@ class LocalStorage @Inject constructor( private fun createCipher(mode: Int, key: Key): Cipher = // TODO: Supported values: https://developer.android.com/reference/kotlin/javax/crypto/Cipher - Cipher.getInstance("RSA/ECB/OAEPPadding").apply { init(mode, key) } + Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding").apply { init(mode, key) } fun store( element: C, From cc1ee5e709d6f36156f8caabb76ed46a233bc350 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 23:54:34 -0700 Subject: [PATCH 09/18] Update --- .../modules/storage/local/LocalStorage.kt | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index 4fd92fc2e..839d792cf 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -17,16 +17,23 @@ class LocalStorage @Inject constructor( private fun createCipher(mode: Int, key: Key): Cipher = // TODO: Supported values: https://developer.android.com/reference/kotlin/javax/crypto/Cipher Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding").apply { init(mode, key) } +g + fun store(storageKey: String, data: ByteArray, settings: LocalStorageSetting) = + store(file(storageKey, ByteArray::class), data, settings) fun store( - element: C, + value: C, storageKey: String? = null, type: KClass, settings: LocalStorageSetting, encode: (C) -> ByteArray, + ) = store(file(storageKey, type), encode(value), settings) + + private fun store( + file: File, + data: ByteArray, + settings: LocalStorageSetting, ) { - val file = file(storageKey, type) - val data = encode(element) val keys = settings.keys(secureStorage) ?: run { file.writeBytes(data) return @@ -41,8 +48,16 @@ class LocalStorage @Inject constructor( type: KClass, settings: LocalStorageSetting, decode: (ByteArray) -> C, + ): C = read(file(storageKey, type), settings, decode) + + fun read(storageKey: String, settings: LocalStorageSetting): ByteArray = + read(file(storageKey, ByteArray::class), settings) { it } + + private fun read( + file: File, + settings: LocalStorageSetting, + decode: (ByteArray) -> C, ): C { - val file = file(storageKey, type) val keys = settings.keys(secureStorage = secureStorage) ?: return decode(file.readBytes()) val data = createCipher(Cipher.DECRYPT_MODE, keys.private) @@ -50,19 +65,11 @@ class LocalStorage @Inject constructor( return decode(data) } - fun delete(storageKey: String?) { - delete(storageKey, String::class) - } + fun delete(storageKey: String) = delete(file(storageKey, String::class)) - fun delete(type: KClass) { - delete(null, type) - } + fun delete(type: KClass) = delete(file(null, type)) - private fun delete( - storageKey: String?, - type: KClass, - ) { - val file = file(storageKey, type) + private fun delete(file: File) { if (file.exists()) { file.delete() } @@ -72,7 +79,7 @@ class LocalStorage @Inject constructor( val filename = storageKey ?: type.qualifiedName ?: type.simpleName - ?: LocalStorageError.FileNameCouldNotBeIdentified + ?: throw LocalStorageError.FileNameCouldNotBeIdentified val directory = File(context.filesDir, "edu.stanford.spezi/LocalStorage") if (!directory.exists()) { directory.mkdirs() From 86e41299b1e50cf43cfc3e02726c527f4925cd26 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Wed, 2 Oct 2024 23:58:26 -0700 Subject: [PATCH 10/18] Remove unnecessary char --- .../edu/stanford/spezi/modules/storage/local/LocalStorage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index 839d792cf..10feb913a 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -17,7 +17,7 @@ class LocalStorage @Inject constructor( private fun createCipher(mode: Int, key: Key): Cipher = // TODO: Supported values: https://developer.android.com/reference/kotlin/javax/crypto/Cipher Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding").apply { init(mode, key) } -g + fun store(storageKey: String, data: ByteArray, settings: LocalStorageSetting) = store(file(storageKey, ByteArray::class), data, settings) From 0d0ff552bf4e13bbec45a477dfa5e5680362f3e2 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Thu, 3 Oct 2024 11:02:20 -0700 Subject: [PATCH 11/18] Review adaptions --- .../storage/local/LocalStorageSetting.kt | 35 ++++++++++--------- .../modules/storage/secure/SecureStorage.kt | 13 ++++++- .../storage/secure/SecureStorageItemTypes.kt | 14 ++++---- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt index a5381c297..61ede39eb 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -11,24 +11,25 @@ sealed class LocalStorageSetting { data object EncryptedUsingKeyStore : LocalStorageSetting() - @Suppress("detekt:ReturnCount") + @Suppress("detekt:TooGenericExceptionCaught") fun keys(secureStorage: SecureStorage): KeyPair? { - val secureStorageScope = when (this) { - is Unencrypted -> return null - is Encrypted -> return keyPair - is EncryptedUsingKeyStore -> - SecureStorageScope.KeyStore - } - - val tag = "LocalStorage.${secureStorageScope.identifier}" - try { - val privateKey = secureStorage.retrievePrivateKey(tag) - val publicKey = secureStorage.retrievePublicKey(tag) - if (privateKey != null && publicKey != null) { - return KeyPair(publicKey, privateKey) + return when (this) { + is Unencrypted -> null + is Encrypted -> keyPair + is EncryptedUsingKeyStore -> { + val identifier = SecureStorageScope.KeyStore.identifier + val tag = "LocalStorage.$identifier" + try { + val privateKey = secureStorage.retrievePrivateKey(tag) + val publicKey = secureStorage.retrievePublicKey(tag) + if (privateKey != null && publicKey != null) { + return KeyPair(publicKey, privateKey) + } + } catch (error: Throwable) { + println("Retrieving private or public key from SecureStorage failed due to `$error`.") + } + secureStorage.createKey(tag) } - } catch (_: Throwable) {} - - return secureStorage.createKey(tag) + } } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt index e1c3797f6..f5601c510 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt @@ -5,6 +5,8 @@ import android.content.SharedPreferences import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import java.security.KeyPair import java.security.KeyPairGenerator @@ -17,7 +19,16 @@ class SecureStorage( ) { private val provider = "AndroidKeyStore" private val keyStore: KeyStore = KeyStore.getInstance(provider).apply { load(null) } - private val preferences: SharedPreferences = context.getSharedPreferences("Spezi_SecureStoragePrefs", Context.MODE_PRIVATE) + private val preferencesKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + private val preferences: SharedPreferences = EncryptedSharedPreferences.create( + context, + "Spezi_SecureStoragePrefs", + preferencesKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) fun createKey( tag: String, diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt index c45895a92..8096e2411 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt @@ -1,36 +1,38 @@ package edu.stanford.spezi.modules.storage.secure +import java.util.EnumSet + enum class SecureStorageItemType { KEYS, SERVER_CREDENTIALS, NON_SERVER_CREDENTIALS, } -data class SecureStorageItemTypes(val types: Set) { +data class SecureStorageItemTypes(val types: EnumSet) { companion object { val keys = SecureStorageItemTypes( - setOf( + EnumSet.of( SecureStorageItemType.KEYS ) ) val serverCredentials = SecureStorageItemTypes( - setOf( + EnumSet.of( SecureStorageItemType.SERVER_CREDENTIALS ) ) val nonServerCredentials = SecureStorageItemTypes( - setOf( + EnumSet.of( SecureStorageItemType.NON_SERVER_CREDENTIALS ) ) val credentials = SecureStorageItemTypes( - setOf( + EnumSet.of( SecureStorageItemType.SERVER_CREDENTIALS, SecureStorageItemType.NON_SERVER_CREDENTIALS ) ) val all = SecureStorageItemTypes( - SecureStorageItemType.entries.toSet() + EnumSet.allOf(SecureStorageItemType::class.java) ) } } From 89000f88b58bcd01f1a0f2bd2944fba990645ac0 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Mon, 14 Oct 2024 16:23:00 -0700 Subject: [PATCH 12/18] Apply suggestion --- .../storage/local/LocalStorageSetting.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt index 61ede39eb..e75c99f24 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -1,5 +1,6 @@ package edu.stanford.spezi.modules.storage.local +import edu.stanford.spezi.core.logging.speziLogger import edu.stanford.spezi.modules.storage.secure.SecureStorage import edu.stanford.spezi.modules.storage.secure.SecureStorageScope import java.security.KeyPair @@ -9,9 +10,10 @@ sealed class LocalStorageSetting { data class Encrypted(val keyPair: KeyPair) : LocalStorageSetting() - data object EncryptedUsingKeyStore : LocalStorageSetting() + data object EncryptedUsingKeyStore : LocalStorageSetting() { + val logger by speziLogger() + } - @Suppress("detekt:TooGenericExceptionCaught") fun keys(secureStorage: SecureStorage): KeyPair? { return when (this) { is Unencrypted -> null @@ -19,16 +21,17 @@ sealed class LocalStorageSetting { is EncryptedUsingKeyStore -> { val identifier = SecureStorageScope.KeyStore.identifier val tag = "LocalStorage.$identifier" - try { + return runCatching { val privateKey = secureStorage.retrievePrivateKey(tag) val publicKey = secureStorage.retrievePublicKey(tag) - if (privateKey != null && publicKey != null) { - return KeyPair(publicKey, privateKey) + return if (privateKey != null && publicKey != null) { + KeyPair(publicKey, privateKey) + } else { + null } - } catch (error: Throwable) { - println("Retrieving private or public key from SecureStorage failed due to `$error`.") - } - secureStorage.createKey(tag) + }.onFailure { failure -> + logger.e(failure) { "Retrieving private or public key from SecureStorage failed" } + }.getOrNull() ?: secureStorage.createKey(tag) } } } From 49d7f04ab86bb2c7049744f75607ae3caafc6653 Mon Sep 17 00:00:00 2001 From: eldcn Date: Mon, 21 Oct 2024 23:24:41 +0200 Subject: [PATCH 13/18] SpeziStorage - Review and API proposals (#123) # SpeziStorage - Review and API proposals ## What was done We have aligned with @pauljohanneskraft that I will bring my proposals in the former PR #108 in a new PR. - In previous PRs we already implemented `KeyValueStorage` which provides an API to write encrypted data for primitive types in [SharedPreferences](https://developer.android.com/reference/android/content/SharedPreferences). This component now got customized to write in shared preferences in encrypted and unencrypted way via `KeyValueStorageFactory#create(fileName, KeyValueStorageType)`. Consumer of the API have the opportunity to either use the default storages, or create a custom one via the factory. Furthermore, since we were using data store in the previous version of LocalKeyValueStorage, it got removed now and all the functions of the key value storage are not suspending anymore - This is safe as shared preferences caches all the changes in memory first, and writes in the disc asynchronously. - `SecureStorage` - image - This component was breaking single responsibility principle in my opinion. On one hand, it was providing methods to store, read, delete and update `Credentials`, on the other hand was providing some methods to create public and private keys from key store (key chain in iOS). Key related methods however, were solely used in the context of `LocalStorage` when using `LocalStorageSetting.EncryptedUsingKeyStore` setting to store a data in a file. - Removes key related from `SecureStorage` and solely provides CRUD methods to it to handle Credentials. Furthermore, the api got extended to retrieve all credentials of a user, and delete credentials per user and per server separately. Under the hood, the `SecureStorage` uses an encrypted `KeyValueStorage` that persists the encrypted json of `Credentials` object. - I would propose to rename this component to `CredentialsSecureStorage` or `CredentialsStorage`, but it requires alignment with iOS - ![image](https://github.com/user-attachments/assets/f517d380-2736-49b8-9a9d-e3b1973279fa) - Introduces public component `AndroidKeyStore` which can be used to create public/private key pairs which then can later on be used in the context of `LocalStorageSetting.Encrypted(KeyPair)` - ![image](https://github.com/user-attachments/assets/fe021ed8-af45-4e3b-a245-b69ff20f3aad) - Each method of `SecureStorage` was requesting `server` as a separate parameter, while it in my opinion belongs to the `Credentials` type - A hint comes also from this [swiftlin disable](https://github.com/StanfordSpezi/SpeziStorage/blob/main/Sources/SpeziSecureStorage/SecureStorage.swift#L310). Hence, `Credentials` now receives a nullable server property as well. - `SecureStorageItemType` was indicating three cases `KEY; SERVER_CREDENTIALS; NON_SERVER_CREDENTIALS;`, however `KEY` was never used in the context of `SecureStorage`. Furthermore, the storage is offering a method to `deleteAllCredentials(SecureStorageItemType)`, if `KEY` would be part of the types, we would be all the `PrivateKey` and `PublicKey`s of the keystore, which is a side effect and a buggy behaviour. I removed `KEY` from `SecureStorageItemType` which complies semantically with `Credentials` type, which can either have a `server` property or not (`null`). - `LocalStorage` - Keeps similar API as iOS by using kotlin serialization - ![image](https://github.com/user-attachments/assets/df0cdbde-1ffa-413c-bdae-45cb8b559334) - Every public component of the library is provides as an interface, and the corresponding implementation as an internal component which is bound by default in hilt graph in `StorageModule.kt`. `StorageModule` (hilt module) serves at the same time also as the public api of the module itself. - All components have been tested - A recording of the public API and generated files and their content: https://github.com/user-attachments/assets/45bfad64-22b4-436f-976c-b136642b5395 ## :gear: Release Notes *Add a bullet point list summary of the feature and possible migration guides if this is a breaking change so this section can be added to the release notes.* *Include code snippets that provide examples of the feature implemented or links to the documentation if it appends or changes the public interface.* ## :books: Documentation *Please ensure that you properly document any additions in conformance to [Spezi Documentation Guide](https://github.com/StanfordSpezi/.github/blob/main/DOCUMENTATIONGUIDE.md).* *You can use this section to describe your solution, but we encourage contributors to document your reasoning and changes using in-line documentation.* ## :white_check_mark: Testing *Please ensure that the PR meets the testing requirements set by CodeCov and that new functionality is appropriately tested.* *This section describes important information about the tests and why some elements might not be testable.* ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [ ] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --------- Co-authored-by: Kilian Schneider <48420258+Basler182@users.noreply.github.com> Co-authored-by: Paul Kraft --- .../domain/BLEPairedDevicesStorage.kt | 28 +- .../domain/BLEPairedDevicesStorageTest.kt | 2 +- .../modules/storage/LocalStorageTests.kt | 40 -- .../modules/storage/SecureStorageTests.kt | 158 ------- .../file/EncryptedFileKeyValueStorageTest.kt | 82 ---- .../key/EncryptedKeyValueStorageTest.kt | 299 ------------- .../storage/key/InMemoryStorageTest.kt | 71 ++-- .../storage/key/KeyValueStorageTest.kt | 337 +++++++++++++++ .../storage/key/LocalKeyValueStorageTest.kt | 393 ------------------ .../storage/local/LocalStorageTests.kt | 214 ++++++++++ .../storage/secure/CredentialStorageTests.kt | 203 +++++++++ .../modules/storage/secure/KeyStorageTests.kt | 70 ++++ .../spezi/modules/storage/di/Storage.kt | 8 + .../spezi/modules/storage/di/StorageModule.kt | 81 +++- .../storage/file/EncryptedFileStorage.kt | 65 --- .../spezi/modules/storage/file/FileStorage.kt | 7 - .../storage/key/EncryptedKeyValueStorage.kt | 116 ------ .../storage/key/InMemoryKeyValueStorage.kt | 64 ++- .../modules/storage/key/KeyValueStorage.kt | 86 +++- .../storage/key/KeyValueStorageFactory.kt | 58 +++ .../storage/key/KeyValueStorageImpl.kt | 83 ++++ .../key/KeyValueStorageSerialization.kt | 51 --- .../storage/key/KeyValueStorageType.kt | 6 + .../storage/key/LocalKeyValueStorage.kt | 128 ------ .../modules/storage/local/LocalStorage.kt | 192 ++++++--- .../storage/local/LocalStorageError.kt | 8 - .../storage/local/LocalStorageSetting.kt | 37 +- .../modules/storage/secure/Credential.kt | 10 + .../storage/secure/CredentialStorage.kt | 97 +++++ .../modules/storage/secure/CredentialType.kt | 11 + .../modules/storage/secure/Credentials.kt | 6 - .../modules/storage/secure/KeyStorage.kt | 85 ++++ .../modules/storage/secure/SecureStorage.kt | 177 +++----- .../storage/secure/SecureStorageItemTypes.kt | 38 -- .../storage/secure/SecureStorageScope.kt | 11 - 35 files changed, 1589 insertions(+), 1733 deletions(-) delete mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt delete mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt delete mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt delete mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt delete mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt create mode 100644 modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorage.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt diff --git a/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt b/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt index ca160d513..7b496cbcc 100644 --- a/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt +++ b/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt @@ -10,7 +10,6 @@ import edu.stanford.spezi.modules.storage.di.Storage import edu.stanford.spezi.modules.storage.key.KeyValueStorage import edu.stanford.spezi.modules.storage.key.getSerializableList import edu.stanford.spezi.modules.storage.key.putSerializable -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,14 +22,11 @@ internal class BLEPairedDevicesStorage @Inject constructor( private val bluetoothAdapter: BluetoothAdapter, private val bleDevicePairingNotifier: BLEDevicePairingNotifier, @Storage.Encrypted - private val encryptedKeyValueStorage: KeyValueStorage, + private val storage: KeyValueStorage, private val timeProvider: TimeProvider, @Dispatching.IO private val ioScope: CoroutineScope, ) { private val logger by speziLogger() - private val coroutineExceptionHandler = CoroutineExceptionHandler { _, error -> - logger.e(error) { "Error executing paired devices storage operations" } - } private val _pairedDevices = MutableStateFlow(emptyList()) val pairedDevices = _pairedDevices.asStateFlow() @@ -40,8 +36,8 @@ internal class BLEPairedDevicesStorage @Inject constructor( observeUnpairingEvents() } - fun updateDeviceConnection(device: BluetoothDevice, connected: Boolean) = execute { - if (isPaired(device).not()) return@execute + fun updateDeviceConnection(device: BluetoothDevice, connected: Boolean) { + if (isPaired(device).not()) return val currentDevices = getCurrentStoredDevices() currentDevices.removeAll { it.address == device.address } val newDevice = BLEDevice( @@ -54,8 +50,8 @@ internal class BLEPairedDevicesStorage @Inject constructor( update(devices = currentDevices + newDevice) } - private fun refreshState() = execute { - val systemBoundDevices = bluetoothAdapter.bondedDevices ?: return@execute + private fun refreshState() { + val systemBoundDevices = bluetoothAdapter.bondedDevices ?: return val newDevices = getCurrentStoredDevices().filter { storedDevice -> systemBoundDevices.any { it.address == storedDevice.address } } @@ -63,15 +59,15 @@ internal class BLEPairedDevicesStorage @Inject constructor( update(devices = newDevices) } - fun onStopped() = execute { + fun onStopped() { val devices = getCurrentStoredDevices().map { it.copy(connected = false, lastSeenTimeStamp = timeProvider.currentTimeMillis()) } update(devices = devices) } - private fun update(devices: List) = execute { - encryptedKeyValueStorage.putSerializable(key = KEY, devices) + private fun update(devices: List) { + storage.putSerializable(key = KEY, devices) _pairedDevices.update { devices } logger.i { "Updating local storage with $devices" } } @@ -112,12 +108,8 @@ internal class BLEPairedDevicesStorage @Inject constructor( } } - private suspend fun getCurrentStoredDevices() = - encryptedKeyValueStorage.getSerializableList(key = KEY).toMutableList() - - private fun execute(block: suspend () -> Unit) { - ioScope.launch(coroutineExceptionHandler) { block() } - } + private fun getCurrentStoredDevices() = + storage.getSerializableList(key = KEY).toMutableList() private companion object { const val KEY = "paired_ble_devices" diff --git a/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt b/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt index b5b13cd8e..5d294e5b3 100644 --- a/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt +++ b/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt @@ -32,7 +32,7 @@ class BLEPairedDevicesStorageTest { private val pairedDevicesStorage by lazy { BLEPairedDevicesStorage( bluetoothAdapter = adapter, - encryptedKeyValueStorage = storage, + storage = storage, ioScope = SpeziTestScope(), bleDevicePairingNotifier = bleDevicePairingNotifier, timeProvider = timeProvider, diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt deleted file mode 100644 index faa00b537..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/LocalStorageTests.kt +++ /dev/null @@ -1,40 +0,0 @@ -package edu.stanford.spezi.modules.storage - -import androidx.test.platform.app.InstrumentationRegistry -import edu.stanford.spezi.modules.storage.local.LocalStorage -import edu.stanford.spezi.modules.storage.local.LocalStorageSetting -import org.junit.Test -import java.nio.charset.StandardCharsets -import kotlin.random.Random - -class LocalStorageTests { - data class Letter(val greeting: String) - - @Test - fun localStorage() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val localStorage = LocalStorage(context) - - var greeting = "Hello Paul 👋" - for (index in 0..Random.nextInt(10)) { - greeting += "🚀" - } - val letter = Letter(greeting = greeting) - localStorage.store( - letter, - type = Letter::class, - settings = LocalStorageSetting.Unencrypted, - encode = { letter.greeting.toByteArray(StandardCharsets.UTF_8) } - ) - val storedLetter: Letter = localStorage.read( - settings = LocalStorageSetting.Unencrypted, - type = Letter::class, - decode = { Letter(it.toString(StandardCharsets.UTF_8)) } - ) - - assert(letter.greeting == storedLetter.greeting) - - localStorage.delete(Letter::class) - localStorage.delete(storageKey = "Letter") - } -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt deleted file mode 100644 index fef6af54a..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/SecureStorageTests.kt +++ /dev/null @@ -1,158 +0,0 @@ -package edu.stanford.spezi.modules.storage - -import androidx.test.platform.app.InstrumentationRegistry -import edu.stanford.spezi.modules.storage.secure.Credentials -import edu.stanford.spezi.modules.storage.secure.SecureStorage -import edu.stanford.spezi.modules.storage.secure.SecureStorageItemTypes -import org.junit.Test -import java.nio.charset.StandardCharsets -import javax.crypto.Cipher - -class SecureStorageTests { - - private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext - private val secureStorage = SecureStorage(targetContext) - - @Test - fun testDeleteAllCredentials() { - val serverCredentials1 = Credentials("@Schmiedmayer", "SpeziInventor") - secureStorage.store(serverCredentials1, "apple.com") - - val serverCredentials2 = Credentials("Stanford Spezi", "Paul") - secureStorage.store(serverCredentials2) - - secureStorage.createKey("DeleteKeyTest") - secureStorage.deleteAllCredentials(SecureStorageItemTypes.all) - - assert(secureStorage.retrieveAllCredentials("apple.com").isEmpty()) - assert(secureStorage.retrieveAllCredentials().isEmpty()) - assert(secureStorage.retrievePrivateKey("DeleteKeyTest") == null) - assert(secureStorage.retrievePublicKey("DeleteKeyTest") == null) - } - - @Test - fun testCredentials() { - secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) - - val serverCredentials0 = Credentials("@PSchmiedmayer", "SpeziInventor") - secureStorage.store(serverCredentials0) - secureStorage.store(serverCredentials0) // Overwrite existing credentials - - val retrievedCredentials0 = secureStorage.retrieveCredentials("@PSchmiedmayer") - assert(serverCredentials0.username == retrievedCredentials0?.username) - assert(serverCredentials0.password == retrievedCredentials0?.password) - - val serverCredentials1 = Credentials("@Spezi", "Paul") - secureStorage.updateCredentials("@PSchmiedmayer", newCredentials = serverCredentials1) - - val retrievedCredentials1 = secureStorage.retrieveCredentials("@Spezi") - assert(serverCredentials1.username == retrievedCredentials1?.username) - assert(serverCredentials1.password == retrievedCredentials1?.password) - - secureStorage.deleteCredentials("@Spezi") - assert(secureStorage.retrieveCredentials("@Spezi") == null) - } - - @Test - fun testInternetCredentials() { - secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) - - val credentials0 = Credentials("@PSchmiedmayer", "SpeziInventor") - secureStorage.store(credentials0, server = "twitter.com") - secureStorage.store(credentials0, server = "twitter.com") // Overwrite existing credentials. - - val retrievedCredentials0 = - secureStorage.retrieveCredentials("@PSchmiedmayer", "twitter.com") - assert(credentials0.username == retrievedCredentials0?.username) - assert(credentials0.password == retrievedCredentials0?.password) - - val credentials1 = Credentials("@Spezi", "Paul") - secureStorage.updateCredentials( - "@PSchmiedmayer", - server = "twitter.com", - newCredentials = credentials1, - newServer = "stanford.edu" - ) - - val retrievedCredentials1 = secureStorage.retrieveCredentials("@Spezi", "stanford.edu") - assert(credentials1.username == retrievedCredentials1?.username) - assert(credentials1.password == retrievedCredentials1?.password) - - secureStorage.deleteCredentials("@Spezi", "stanford.edu") - assert(secureStorage.retrieveCredentials("@Spezi", "stanford.edu") == null) - } - - @Test - fun testMultipleInternetCredentials() { - secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) - - val credentials0 = Credentials("Paul Schmiedmayer", "SpeziInventor") - secureStorage.store(credentials0, "linkedin.com") - - val credentials1 = Credentials("Stanford Spezi", "Paul") - secureStorage.store(credentials1, "linkedin.com") - - val retrievedCredentials = secureStorage.retrieveAllCredentials(server = "linkedin.com") - assert(retrievedCredentials.size == 2) - - assert(retrievedCredentials.firstOrNull { it.username == credentials0.username }?.password == credentials0.password) - assert(retrievedCredentials.firstOrNull { it.username == credentials1.username }?.password == credentials1.password) - - secureStorage.deleteCredentials("Paul Schmiedmayer", server = "linkedin.com") - secureStorage.deleteCredentials("Stanford Spezi", server = "linkedin.com") - - val retrievedCredentialsEmpty = secureStorage.retrieveAllCredentials("linkedin.com") - assert(retrievedCredentialsEmpty.isEmpty()) - } - - @Test - fun testMultipleCredentials() { - secureStorage.deleteAllCredentials(SecureStorageItemTypes.credentials) - - val credentials0 = Credentials("Paul Schmiedmayer", "SpeziInventor") - secureStorage.store(credentials0) - - val credentials1 = Credentials("Stanford Spezi", "Paul") - secureStorage.store(credentials1) - - val retrievedCredentials = secureStorage.retrieveAllCredentials() - assert(retrievedCredentials.size == 2) - - assert(retrievedCredentials.firstOrNull { it.username == credentials0.username }?.password == credentials0.password) - assert(retrievedCredentials.firstOrNull { it.username == credentials1.username }?.password == credentials1.password) - - secureStorage.deleteCredentials("Paul Schmiedmayer") - secureStorage.deleteCredentials("Stanford Spezi") - - val retrievedCredentialsEmpty = secureStorage.retrieveAllCredentials() - assert(retrievedCredentialsEmpty.isEmpty()) - } - - @Test - fun testKeys() { - secureStorage.deleteAllCredentials(SecureStorageItemTypes.keys) - assert(secureStorage.retrievePublicKey("MyKey") == null) - - val keyPair = secureStorage.createKey("MyKey") - - val privateKey = keyPair.private - assert(secureStorage.retrievePrivateKey("MyKey") == privateKey) - - val publicKey = keyPair.public - assert(secureStorage.retrievePublicKey("MyKey") == publicKey) - - val plainText = "Spezi & Paul Schmiedmayer".toByteArray(StandardCharsets.UTF_8) - println(plainText.toString(StandardCharsets.UTF_8)) - - val encipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") - encipher.init(Cipher.ENCRYPT_MODE, publicKey) - val encryptedBytes = encipher.doFinal(plainText) - - val decipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding") - decipher.init(Cipher.DECRYPT_MODE, privateKey) - val decryptedBytes = decipher.doFinal(encryptedBytes) - println(decryptedBytes.toString(StandardCharsets.UTF_8)) - - assert(decryptedBytes.contentEquals(plainText)) - } -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt deleted file mode 100644 index 606119c14..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package edu.stanford.spezi.modules.storage.file - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import edu.stanford.spezi.core.testing.runTestUnconfined -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import org.junit.After -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class EncryptedFileKeyValueStorageTest { - private var context: Context = ApplicationProvider.getApplicationContext() - private var fileStorage: FileStorage = - EncryptedFileStorage( - context = context, - ioDispatcher = UnconfinedTestDispatcher(), - ) - private val fileName = "testFile" - - @After - fun tearDown() = runTestUnconfined { - // Delete the file after each test to clean up - fileStorage.deleteFile(fileName) - } - - @Test - fun `it should save and read file correctly`() = runTestUnconfined { - // Given - val data = "Hello, World!".toByteArray() - - // When - fileStorage.saveFile(fileName, data) - - // Then - val readData = fileStorage.readFile(fileName).getOrNull() - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return null when reading non-existent file`() = runTestUnconfined { - // Given - val fileName = "nonExistentFile" - - // When - val readData = fileStorage.readFile(fileName).getOrNull() - - // Then - assertThat(readData).isNull() - } - - @Test - fun `it should overwrite existing file when saving with same filename`() = runTestUnconfined { - // Given - val initialData = "Hello, World!".toByteArray() - val newData = "New data".toByteArray() - - // When - fileStorage.saveFile(fileName, initialData) - fileStorage.saveFile(fileName, newData) - - // Then - val readData = fileStorage.readFile(fileName).getOrNull() - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete file correctly`() = runTestUnconfined { - // Given - val data = "Hello, World!".toByteArray() - fileStorage.saveFile(fileName, data) - - // When - fileStorage.deleteFile(fileName) - - // Then - val readData = fileStorage.readFile(fileName).getOrNull() - assertThat(readData).isNull() - } -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt deleted file mode 100644 index e059b206d..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt +++ /dev/null @@ -1,299 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat -import edu.stanford.spezi.core.testing.runTestUnconfined -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.serialization.Serializable -import org.junit.Test - -class EncryptedKeyValueStorageTest { - - private val context = ApplicationProvider.getApplicationContext() - private var storage: EncryptedKeyValueStorage = - EncryptedKeyValueStorage(context, UnconfinedTestDispatcher()) - - private val key = "test_key" - - @Test - fun `it should save and read string data correctly`() = runTestUnconfined { - // given - val expectedValue = "Test String" - - // when - storage.putString(key, expectedValue) - - // then - val actualValue = storage.getString(key, "") - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent string data`() = runTestUnconfined { - // given - val default = "default" - - // when - val actualValue = storage.getString(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete string data correctly`() = runTestUnconfined { - // given - val value = "Test String" - storage.putString(key, value) - - // when - storage.deleteString(key) - - // then - val actualValue = storage.getString(key, "default") - assertThat(actualValue).isEqualTo("default") - } - - @Test - fun `it should save and read int data correctly`() = runTestUnconfined { - // given - val expectedValue = 42 - - // when - storage.putInt(key, expectedValue) - - // then - val actualValue = storage.getInt(key, 0) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent int data`() = runTestUnconfined { - // given - val default = 0 - - // when - val actualValue = storage.getInt(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete int data correctly`() = runTestUnconfined { - // given - val value = 42 - storage.putInt(key, value) - - // when - storage.deleteInt(key) - - // then - val actualValue = storage.getInt(key, 0) - assertThat(actualValue).isEqualTo(0) - } - - @Test - fun `it should save and read boolean data correctly`() = runTestUnconfined { - // given - val expectedValue = true - - // when - storage.putBoolean(key, expectedValue) - - // then - val actualValue = storage.getBoolean(key, false) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent boolean data`() = runTestUnconfined { - // given - val default = false - - // when - val actualValue = storage.getBoolean(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete boolean data correctly`() = runTestUnconfined { - // given - storage.putBoolean(key, true) - - // when - storage.deleteBoolean(key) - - // then - val actualValue = storage.getBoolean(key, false) - assertThat(actualValue).isEqualTo(false) - } - - @Test - fun `it should save and read float data correctly`() = runTestUnconfined { - // given - val expectedValue = 3.14f - - // when - storage.putFloat(key, expectedValue) - - // then - val actualValue = storage.getFloat(key, 0f) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent float data`() = runTestUnconfined { - // given - val default = 0f - - // when - val actualValue = storage.getFloat(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete float data correctly`() = runTestUnconfined { - // given - val value = 3.14f - storage.putFloat(key, value) - - // when - storage.deleteFloat(key) - - // then - val actualValue = storage.getFloat(key, 0f) - assertThat(actualValue).isEqualTo(0f) - } - - @Test - fun `it should save and read long data correctly`() = runTestUnconfined { - // given - val expectedValue = 123456789L - - // when - storage.putLong(key, expectedValue) - - // then - val actualValue = storage.getLong(key, 0L) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent long data`() = runTestUnconfined { - // given - val default = 0L - - // when - val actualValue = storage.getLong(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete long data correctly`() = runTestUnconfined { - // given - val value = 123456789L - storage.putLong(key, value) - - // when - storage.deleteLong(key) - - // then - val actualValue = storage.getLong(key, 0L) - assertThat(actualValue).isEqualTo(0L) - } - - @Test - fun `it should save and read byte array data correctly`() = runTestUnconfined { - // given - val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) - - // when - storage.putByteArray(key, expectedValue) - - // then - val actualValue = storage.getByteArray(key, byteArrayOf()) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent byte array data`() = runTestUnconfined { - // given - val default = byteArrayOf() - - // when - val actualValue = storage.getByteArray(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete byte array data correctly`() = runTestUnconfined { - // given - val value = byteArrayOf(0x01, 0x02, 0x03, 0x04) - storage.putByteArray(key, value) - - // when - storage.deleteByteArray(key) - - // then - val actualValue = storage.getByteArray(key, byteArrayOf()) - assertThat(actualValue).isEqualTo(byteArrayOf()) - } - - @Test - fun `it should handle serializable type correctly`() = runTestUnconfined { - // given - val data = Complex() - storage.putSerializable(key, data) - - // when - val contains = storage.getSerializable(key) == data - storage.deleteSerializable(key) - val deleted = storage.getSerializable(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle serializable list read correctly`() = runTestUnconfined { - // given - val data = listOf(Complex()) - storage.putSerializable(key, data) - - // when - val contains = storage.getSerializableList(key) == data - storage.deleteSerializable>(key) - val deleted = storage.getSerializable>(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle clear correctly`() = runTestUnconfined { - // given - val initialValue = 1234 - storage.putInt(key, initialValue) - - // when - storage.clear() - - // then - assertThat(storage.getInt(key, -1)).isNotEqualTo(initialValue) - } - - @Serializable - data class Complex(val id: Int = 1) -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt index 42483cf07..233f665da 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt @@ -1,7 +1,6 @@ package edu.stanford.spezi.modules.storage.key import com.google.common.truth.Truth.assertThat -import edu.stanford.spezi.core.testing.runTestUnconfined import kotlinx.serialization.Serializable import org.junit.Test @@ -11,7 +10,7 @@ class InMemoryStorageTest { private val key = "local_storage_test_key" @Test - fun `it should save and read string data correctly`() = runTestUnconfined { + fun `it should save and read string data correctly`() { // given val expectedValue = "Test String" @@ -24,7 +23,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent string data`() = runTestUnconfined { + fun `it should return default when reading non-existent string data`() { // given val default = "default" @@ -36,13 +35,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete string data correctly`() = runTestUnconfined { + fun `it should delete string data correctly`() { // given val value = "Test String" storage.putString(key, value) // when - storage.deleteString(key) + storage.delete(key) // then val actualValue = storage.getString(key, "default") @@ -50,7 +49,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read int data correctly`() = runTestUnconfined { + fun `it should save and read int data correctly`() { // given val expectedValue = 42 @@ -63,7 +62,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent int data`() = runTestUnconfined { + fun `it should return default when reading non-existent int data`() { // given val default = 0 @@ -75,13 +74,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete int data correctly`() = runTestUnconfined { + fun `it should delete int data correctly`() { // given val value = 42 storage.putInt(key, value) // when - storage.deleteInt(key) + storage.delete(key) // then val actualValue = storage.getInt(key, 0) @@ -89,7 +88,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read boolean data correctly`() = runTestUnconfined { + fun `it should save and read boolean data correctly`() { // given val expectedValue = true @@ -102,7 +101,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent boolean data`() = runTestUnconfined { + fun `it should return default when reading non-existent boolean data`() { // given val default = false @@ -114,12 +113,12 @@ class InMemoryStorageTest { } @Test - fun `it should delete boolean data correctly`() = runTestUnconfined { + fun `it should delete boolean data correctly`() { // given storage.putBoolean(key, true) // when - storage.deleteBoolean(key) + storage.delete(key) // then val actualValue = storage.getBoolean(key, false) @@ -127,7 +126,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read float data correctly`() = runTestUnconfined { + fun `it should save and read float data correctly`() { // given val expectedValue = 3.14f @@ -140,7 +139,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent float data`() = runTestUnconfined { + fun `it should return default when reading non-existent float data`() { // given val default = 0f @@ -152,13 +151,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete float data correctly`() = runTestUnconfined { + fun `it should delete float data correctly`() { // given val value = 3.14f storage.putFloat(key, value) // when - storage.deleteFloat(key) + storage.delete(key) // then val actualValue = storage.getFloat(key, 0f) @@ -166,7 +165,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read long data correctly`() = runTestUnconfined { + fun `it should save and read long data correctly`() { // given val expectedValue = 123456789L @@ -179,7 +178,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent long data`() = runTestUnconfined { + fun `it should return default when reading non-existent long data`() { // given val default = 0L @@ -191,13 +190,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete long data correctly`() = runTestUnconfined { + fun `it should delete long data correctly`() { // given val value = 123456789L storage.putLong(key, value) // when - storage.deleteLong(key) + storage.delete(key) // then val actualValue = storage.getLong(key, 0L) @@ -205,7 +204,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read byte array data correctly`() = runTestUnconfined { + fun `it should save and read byte array data correctly`() { // given val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) @@ -218,7 +217,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent byte array data`() = runTestUnconfined { + fun `it should return default when reading non-existent byte array data`() { // given val default = byteArrayOf() @@ -230,13 +229,25 @@ class InMemoryStorageTest { } @Test - fun `it should delete byte array data correctly`() = runTestUnconfined { + fun `it should return null when reading non-existent byte array data`() { + // given + storage.clear() + + // when + val actualValue = storage.getByteArray(key) + + // then + assertThat(actualValue).isNull() + } + + @Test + fun `it should delete byte array data correctly`() { // given val value = byteArrayOf(0x01, 0x02, 0x03, 0x04) storage.putByteArray(key, value) // when - storage.deleteByteArray(key) + storage.delete(key) // then val actualValue = storage.getByteArray(key, byteArrayOf()) @@ -244,14 +255,14 @@ class InMemoryStorageTest { } @Test - fun `it should handle serializable type correctly`() = runTestUnconfined { + fun `it should handle serializable type correctly`() { // given val data = Complex() storage.putSerializable(key, data) // when val contains = storage.getSerializable(key) == data - storage.deleteSerializable(key) + storage.delete(key) val deleted = storage.getSerializable(key) == null // then @@ -260,14 +271,14 @@ class InMemoryStorageTest { } @Test - fun `it should handle serializable list read correctly`() = runTestUnconfined { + fun `it should handle serializable list read correctly`() { // given val data = listOf(Complex()) storage.putSerializable(key, data) // when val contains = storage.getSerializableList(key) == data - storage.deleteSerializable>(key) + storage.delete(key) val deleted = storage.getSerializable>(key) == null // then @@ -276,7 +287,7 @@ class InMemoryStorageTest { } @Test - fun `it should handle clear correctly`() = runTestUnconfined { + fun `it should handle clear correctly`() { // given val initialValue = 1234 storage.putInt(key, initialValue) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt new file mode 100644 index 000000000..6e045ea41 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt @@ -0,0 +1,337 @@ +package edu.stanford.spezi.modules.storage.key + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.modules.storage.di.Storage +import kotlinx.serialization.Serializable +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class KeyValueStorageTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Storage.Encrypted + @Inject + lateinit var encryptedStorage: KeyValueStorage + + @Storage.Unencrypted + @Inject + lateinit var unencryptedStorage: KeyValueStorage + + private val key = "test_key" + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() { + encryptedStorage.clear() + unencryptedStorage.clear() + } + + @Test + fun `it should save and read string data correctly`() = runAllStoragesTest { + // given + val expectedValue = "Test String" + + // when + putString(key, expectedValue) + + // then + val actualValue = getString(key, "") + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent string data`() = runAllStoragesTest { + // given + val default = "default" + + // when + val actualValue = getString(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete string data correctly`() = runAllStoragesTest { + // given + val value = "Test String" + putString(key, value) + + // when + delete(key) + + // then + val actualValue = getString(key, "default") + assertThat(actualValue).isEqualTo("default") + } + + @Test + fun `it should save and read int data correctly`() = runAllStoragesTest { + // given + val expectedValue = 42 + + // when + putInt(key, expectedValue) + + // then + val actualValue = getInt(key, 0) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent int data`() = runAllStoragesTest { + // given + val default = 0 + + // when + val actualValue = getInt(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete int data correctly`() = runAllStoragesTest { + // given + val value = 42 + putInt(key, value) + + // when + delete(key) + + // then + val actualValue = getInt(key, 0) + assertThat(actualValue).isEqualTo(0) + } + + @Test + fun `it should save and read boolean data correctly`() = runAllStoragesTest { + // given + val expectedValue = true + + // when + putBoolean(key, expectedValue) + + // then + val actualValue = getBoolean(key, false) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent boolean data`() = runAllStoragesTest { + // given + val default = false + + // when + val actualValue = getBoolean(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete boolean data correctly`() = runAllStoragesTest { + // given + putBoolean(key, true) + + // when + delete(key) + + // then + val actualValue = getBoolean(key, false) + assertThat(actualValue).isEqualTo(false) + } + + @Test + fun `it should save and read float data correctly`() = runAllStoragesTest { + // given + val expectedValue = 3.14f + + // when + putFloat(key, expectedValue) + + // then + val actualValue = getFloat(key, 0f) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent float data`() = runAllStoragesTest { + // given + val default = 0f + + // when + val actualValue = getFloat(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete float data correctly`() = runAllStoragesTest { + // given + val value = 3.14f + putFloat(key, value) + + // when + delete(key) + + // then + val actualValue = getFloat(key, 0f) + assertThat(actualValue).isEqualTo(0f) + } + + @Test + fun `it should save and read long data correctly`() = runAllStoragesTest { + // given + val expectedValue = 123456789L + + // when + putLong(key, expectedValue) + + // then + val actualValue = getLong(key, 0L) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent long data`() = runAllStoragesTest { + // given + val default = 0L + + // when + val actualValue = getLong(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete long data correctly`() = runAllStoragesTest { + // given + val value = 123456789L + putLong(key, value) + + // when + delete(key) + + // then + val actualValue = getLong(key, 0L) + assertThat(actualValue).isEqualTo(0L) + } + + @Test + fun `it should save and read byte array data correctly`() = runAllStoragesTest { + // given + val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) + + // when + putByteArray(key, expectedValue) + + // then + val actualValue = getByteArray(key, byteArrayOf()) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent byte array data`() = runAllStoragesTest { + // given + val default = byteArrayOf() + + // when + val actualValue = getByteArray(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should return null when reading non-existent byte array data`() = runAllStoragesTest { + // given + clear() + + // when + val actualValue = getByteArray(key) + + // then + assertThat(actualValue).isNull() + } + + @Test + fun `it should delete byte array data correctly`() = runAllStoragesTest { + // given + val value = byteArrayOf(0x01, 0x02, 0x03, 0x04) + putByteArray(key, value) + + // when + delete(key) + + // then + val actualValue = getByteArray(key, byteArrayOf()) + assertThat(actualValue).isEqualTo(byteArrayOf()) + } + + @Test + fun `it should handle serializable type correctly`() = runAllStoragesTest { + // given + val data = Complex() + putSerializable(key, data) + + // when + val contains = getSerializable(key) == data + delete(key) + val deleted = getSerializable(key) == null + + // then + assertThat(contains).isTrue() + assertThat(deleted).isTrue() + } + + @Test + fun `it should handle serializable list read correctly`() = runAllStoragesTest { + // given + val data = listOf(Complex()) + putSerializable(key, data) + + // when + val contains = getSerializableList(key) == data + delete(key) + val deleted = getSerializable>(key) == null + + // then + assertThat(contains).isTrue() + assertThat(deleted).isTrue() + } + + @Test + fun `it should handle clear correctly`() = runAllStoragesTest { + // given + val initialValue = 1234 + putInt(key, initialValue) + + // when + clear() + + // then + assertThat(getInt(key, -1)).isNotEqualTo(initialValue) + } + + private fun runAllStoragesTest(block: KeyValueStorage.() -> Unit) { + listOf(encryptedStorage, unencryptedStorage).forEach { block(it) } + } + + @Serializable + data class Complex(val id: Int = 1) +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt deleted file mode 100644 index baa68d591..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt +++ /dev/null @@ -1,393 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import edu.stanford.spezi.core.testing.runTestUnconfined -import kotlinx.serialization.Serializable -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class LocalKeyValueStorageTest { - private var context: Context = ApplicationProvider.getApplicationContext() - private var localStorage: LocalKeyValueStorage = LocalKeyValueStorage(context) - private val key = "local_storage_test_key" - - @Before - fun before() = runTestUnconfined { - localStorage.clear() - } - - @Test - fun `it should save and read String data correctly`() = runTestUnconfined { - // given - val data = "Hello, Leland Stanford!" - - // when - localStorage.putString(key, data) - - // then - val readData = localStorage.getString(key, "") - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default String when reading non-existent data`() = runTestUnconfined { - // given - val default = "default string" - - // when - val readData = localStorage.getString(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing String data`() = runTestUnconfined { - // given - val initialData = "Initial String" - val newData = "Updated String" - - // when - localStorage.putString(key, initialData) - localStorage.putString(key, newData) - - // then - val readData = localStorage.getString(key, "") - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete String data`() = runTestUnconfined { - // given - val data = "Some String" - - // when - localStorage.putString(key, data) - localStorage.deleteString(key) - - // then - val readData = localStorage.getString(key, "") - assertThat(readData).isEqualTo("") - } - - @Test - fun `it should save and read Boolean data correctly`() = runTestUnconfined { - // given - val data = true - - // when - localStorage.putBoolean(key, data) - - // then - val readData = localStorage.getBoolean(key, false) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Boolean when reading non-existent data`() = runTestUnconfined { - // given - val default = false - - // when - val readData = localStorage.getBoolean(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Boolean data`() = runTestUnconfined { - // given - val initialData = true - val newData = false - - // when - localStorage.putBoolean(key, initialData) - localStorage.putBoolean(key, newData) - - // then - val readData = localStorage.getBoolean(key, true) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Boolean data`() = runTestUnconfined { - // given - val data = true - - // when - localStorage.putBoolean(key, data) - localStorage.deleteBoolean(key) - - // then - val readData = localStorage.getBoolean(key, false) - assertThat(readData).isEqualTo(false) - } - - @Test - fun `it should save and read Long data correctly`() = runTestUnconfined { - // given - val data = 12345L - - // when - localStorage.putLong(key, data) - - // then - val readData = localStorage.getLong(key, 0L) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Long when reading non-existent data`() = runTestUnconfined { - // given - val default = 0L - - // when - val readData = localStorage.getLong(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Long data`() = runTestUnconfined { - // given - val initialData = 12345L - val newData = 67890L - - // when - localStorage.putLong(key, initialData) - localStorage.putLong(key, newData) - - // then - val readData = localStorage.getLong(key, 0L) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Long data`() = runTestUnconfined { - // given - val data = 12345L - - // when - localStorage.putLong(key, data) - localStorage.deleteLong(key) - - // then - val readData = localStorage.getLong(key, 0L) - assertThat(readData).isEqualTo(0L) - } - - @Test - fun `it should save and read Int data correctly`() = runTestUnconfined { - // given - val data = 42 - - // when - localStorage.putInt(key, data) - - // then - val readData = localStorage.getInt(key, 0) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Int when reading non-existent data`() = runTestUnconfined { - // given - val default = 0 - - // then - val readData = localStorage.getInt(key, default) - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Int data`() = runTestUnconfined { - // given - val initialData = 42 - val newData = 100 - - // when - localStorage.putInt(key, initialData) - localStorage.putInt(key, newData) - - // then - val readData = localStorage.getInt(key, 0) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Int data`() = runTestUnconfined { - // given - val data = 42 - localStorage.putInt(key, data) - - // when - localStorage.deleteInt(key) - - // then - val readData = localStorage.getInt(key, 0) - assertThat(readData).isEqualTo(0) - } - - @Test - fun `it should save and read Float data correctly`() = runTestUnconfined { - // given - val data = 3.14f - - // when - localStorage.putFloat(key, data) - - // then - val readData = localStorage.getFloat(key, 0.0f) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Float when reading non-existent data`() = runTestUnconfined { - // given - val default = 0.0f - - // when - val readData = localStorage.getFloat(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Float data`() = runTestUnconfined { - // given - val initialData = 3.14f - val newData = 2.71f - - // when - localStorage.putFloat(key, initialData) - localStorage.putFloat(key, newData) - - // then - val readData = localStorage.getFloat(key, 0.0f) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Float data`() = runTestUnconfined { - // given - val data = 3.14f - - // when - localStorage.putFloat(key, data) - localStorage.deleteFloat(key) - - // then - val readData = localStorage.getFloat(key, 0.0f) - assertThat(readData).isEqualTo(0.0f) - } - - @Test - fun `it should save and read ByteArray data correctly`() = runTestUnconfined { - // given - val data = byteArrayOf(1, 2, 3, 4) - - // when - localStorage.putByteArray(key, data) - - // then - val readData = localStorage.getByteArray(key, byteArrayOf()) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default ByteArray when reading non-existent data`() = runTestUnconfined { - // given - val default = byteArrayOf(0) - - // when - val readData = localStorage.getByteArray(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing ByteArray data`() = runTestUnconfined { - // given - val initialData = byteArrayOf(1, 2, 3, 4) - val newData = byteArrayOf(5, 6, 7, 8) - - // when - localStorage.putByteArray(key, initialData) - localStorage.putByteArray(key, newData) - - // then - val readData = localStorage.getByteArray(key, byteArrayOf()) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete ByteArray data`() = runTestUnconfined { - // given - val data = byteArrayOf(1, 2, 3, 4) - - // when - localStorage.putByteArray(key, data) - localStorage.deleteByteArray(key) - - // then - val readData = localStorage.getByteArray(key, byteArrayOf()) - assertThat(readData).isEqualTo(byteArrayOf()) - } - - @Test - fun `it should handle serializable type correctly`() = runTestUnconfined { - // given - val data = Complex() - localStorage.putSerializable(key, data) - - // when - val contains = localStorage.getSerializable(key) == data - localStorage.deleteSerializable(key) - val deleted = localStorage.getSerializable(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle serializable list read correctly`() = runTestUnconfined { - // given - val data = listOf(Complex()) - localStorage.putSerializable(key, data) - - // when - val contains = localStorage.getSerializableList(key) == data - localStorage.deleteSerializable>(key) - val deleted = localStorage.getSerializable>(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle clear correctly`() = runTestUnconfined { - // given - val initialValue = 1234 - localStorage.putInt(key, initialValue) - - // when - localStorage.clear() - - // then - assertThat(localStorage.getInt(key, -1)).isNotEqualTo(initialValue) - } - - @Serializable - data class Complex(val id: Int = 1) -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt new file mode 100644 index 000000000..82015c99c --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt @@ -0,0 +1,214 @@ +package edu.stanford.spezi.modules.storage.local + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.core.utils.UUID +import edu.stanford.spezi.modules.storage.secure.KeyStorage +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.nio.charset.StandardCharsets +import javax.inject.Inject +import kotlin.random.Random + +@HiltAndroidTest +class LocalStorageTests { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var localStorage: LocalStorage + + @Inject + lateinit var keyStorage: KeyStorage + + private val key = "storage_key" + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() = runTestUnconfined { + localStorage.delete(key = key) + keyStorage.deleteAll() + } + + @Serializable + data class Letter(val greeting: String) + + @Test + fun `it should handle complex type correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letter = Letter(greeting = greeting) + localStorage.store( + key = key, + value = letter, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + + // when + val storedLetter = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer(), + ) + + // then + assertThat(letter).isEqualTo(storedLetter) + } + + @Test + fun `it should handle custom coding correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letter = Letter(greeting = greeting) + localStorage.store( + key = key, + value = letter, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + encoding = { Json.encodeToString(serializer(), it).toByteArray(StandardCharsets.UTF_8) } + ) + + // when + val storedLetter = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + decoding = { Json.decodeFromString(serializer(), String(it, StandardCharsets.UTF_8)) } + ) + + // then + assertThat(letter).isEqualTo(storedLetter) + } + + @Test + fun `it should handle deletion of complex type correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letter = Letter(greeting = greeting) + localStorage.store( + key = key, + value = letter, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + val storedLetter = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer(), + ) + + // when + localStorage.delete(key) + + // then + val afterDelete = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer(), + ) + assertThat(letter).isEqualTo(storedLetter) + assertThat(afterDelete).isNull() + } + + @Test + fun `it should handle list of complex types correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letters = listOf(Letter(greeting = greeting)) + localStorage.store( + key = key, + value = letters, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = ListSerializer(serializer()) + ) + + // when + val storedLetters = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = ListSerializer(serializer()), + ) + + // then + assertThat(letters).isEqualTo(storedLetters) + } + + @Test + fun `it should handle primitive type correctly`() = runTestUnconfined { + // given + val value = Random.nextBoolean() + localStorage.store( + key = key, + value = value, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + + // when + val storedValue = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + + // then + assertThat(value).isEqualTo(storedValue) + } + + @Test + fun `it should handle Unencrypted setting correctly`() = runTestUnconfined { + // given + val value = UUID().toString() + localStorage.store( + key = key, + value = value, + settings = LocalStorageSetting.Unencrypted, + serializer = serializer() + ) + + // when + val storedValue = localStorage.read( + key = key, + settings = LocalStorageSetting.Unencrypted, + serializer = serializer() + ) + + // then + assertThat(value).isEqualTo(storedValue) + } + + @Test + fun `it should handle Encrypted with custom key pair setting correctly`() = runTestUnconfined { + // given + val value = UUID().toString() + val androidKeyStoreKey = "androidKeyStoreKey" + val keyPair = keyStorage.create(androidKeyStoreKey).getOrThrow() + localStorage.store( + key = key, + value = value, + settings = LocalStorageSetting.Encrypted(keyPair), + serializer = serializer() + ) + + // when + val storedValue = localStorage.read( + key = key, + settings = LocalStorageSetting.Encrypted(keyPair), + serializer = serializer() + ) + + // then + assertThat(value).isEqualTo(storedValue) + } +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt new file mode 100644 index 000000000..cb6c4af03 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt @@ -0,0 +1,203 @@ +package edu.stanford.spezi.modules.storage.secure + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.core.utils.UUID +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.EnumSet +import javax.inject.Inject + +@HiltAndroidTest +class CredentialStorageTests { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var credentialStorage: CredentialStorage + + private val serverCredential = Credential( + username = "@Schmiedmayer", + password = "top-secret", + server = "apple.com" + ) + + private val nonServerCredential = Credential( + username = "@Spezi", + password = "123456", + ) + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() { + credentialStorage.deleteAll(CredentialType.ALL) + } + + @Test + fun `it should store server credentials correctly`() { + // given + credentialStorage.store(serverCredential) + + // when + val serverCredentials = credentialStorage.retrieveAll(server = serverCredential.server) + val userServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + + // then + assertThat(serverCredentials).containsExactly(serverCredential) + assertThat(userServerCredential).isEqualTo(serverCredential) + } + + @Test + fun `it should store non server credentials correctly`() { + // given + credentialStorage.store(nonServerCredential) + + // when + val userServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username + ) + val userCredentials = credentialStorage.retrieve(nonServerCredential.username) + + // then + assertThat(userCredentials).isEqualTo(nonServerCredential) + assertThat(userServerCredential).isEqualTo(nonServerCredential) + } + + @Test + fun `it should retrieve all server credentials correctly`() { + // given + val server = "edu.stanford.spezi" + val credentials = List(10) { serverCredential.copy(username = "User$it", server = server) } + credentials.forEach { credentialStorage.store(it) } + + // when + val storedCredentials = credentialStorage.retrieveAll(server) + + // then + assertThat(storedCredentials).containsExactlyElementsIn(credentials) + assertThat(storedCredentials.all { it.server == server }).isTrue() + } + + @Test + fun `it should update credentials correctly`() { + // given + val updatedUserName = serverCredential.username + "- @Spezi" + val newPassword = serverCredential.password.plus(UUID().toString()) + credentialStorage.store(serverCredential) + val updatedCredential = serverCredential.copy( + username = updatedUserName, + password = newPassword, + ) + credentialStorage.update( + username = serverCredential.username, + server = serverCredential.server, + newCredential = updatedCredential, + ) + + // when + val oldCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + val newCredential = credentialStorage.retrieve( + username = updatedCredential.username, + server = updatedCredential.server, + ) + + // then + assertThat(oldCredential).isNull() + assertThat(newCredential).isEqualTo(updatedCredential) + } + + @Test + fun `it should delete credentials correctly`() { + // given + credentialStorage.store(serverCredential) + val beforeDeleteCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = "apple.com", + ) + + // when + credentialStorage.delete( + username = serverCredential.username, + server = serverCredential.server, + ) + val afterDeleteCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + + // then + assertThat(beforeDeleteCredential).isEqualTo(serverCredential) + assertThat(afterDeleteCredential).isNull() + } + + @Test + fun `it should handle deleting all server credentials correctly`() { + // given + listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } + + // when + credentialStorage.deleteAll(EnumSet.of(CredentialType.SERVER)) + val storedServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + ) + val storedNonServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username, + ) + + // then + assertThat(storedServerCredential).isNull() + assertThat(storedNonServerCredential).isEqualTo(nonServerCredential) + } + + @Test + fun `it should handle deleting non server credentials correctly`() { + // given + listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } + + // when + credentialStorage.deleteAll(EnumSet.of(CredentialType.NON_SERVER)) + val storedServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + val storedNonServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username, + ) + + // then + assertThat(storedServerCredential).isEqualTo(serverCredential) + assertThat(storedNonServerCredential).isNull() + } + + @Test + fun `it should handle deleting all credentials correctly`() { + // given + listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } + + // when + credentialStorage.deleteAll(CredentialType.ALL) + val storedServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + ) + val storedNonServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username, + ) + + // then + assertThat(storedServerCredential).isNull() + assertThat(storedNonServerCredential).isNull() + } +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt new file mode 100644 index 000000000..75d6ac470 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt @@ -0,0 +1,70 @@ +package edu.stanford.spezi.modules.storage.secure + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class KeyStorageTests { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var keyStorage: KeyStorage + + private val keyName = "TestKey" + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun `it should create keys correctly`() { + // given + val key = keyStorage.create(keyName).getOrThrow() + + // when + val keyPair = keyStorage.retrieveKeyPair(keyName) + val privateKey = keyStorage.retrievePrivateKey(keyName) + val publicKey = keyStorage.retrievePublicKey(keyName) + + // then + assertThat(privateKey).isEqualTo(key.private) + assertThat(privateKey).isEqualTo(keyPair?.private) + assertThat(publicKey).isEqualTo(key.public) + assertThat(publicKey).isEqualTo(keyPair?.public) + } + + @Test + fun `it should handle key deletion correctly`() { + // given + keyStorage.create(keyName) + + // when + keyStorage.delete(keyName) + val privateKey = keyStorage.retrievePrivateKey(keyName) + val publicKey = keyStorage.retrievePublicKey(keyName) + + // then + assertThat(privateKey).isNull() + assertThat(publicKey).isNull() + } + + @Test + fun `it should handle clear correctly`() { + // given + keyStorage.create(keyName) + + // when + keyStorage.deleteAll() + + // then + assertThat(keyStorage.retrieveKeyPair(keyName)).isNull() + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt index cb7999d7e..574e79b32 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt @@ -7,4 +7,12 @@ interface Storage { @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Encrypted + + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class Unencrypted + + companion object { + internal const val STORAGE_FILE_PREFIX = "edu.stanford.spezi.storage." + } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt index 84d5de38d..bf7fa6971 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt @@ -2,18 +2,87 @@ package edu.stanford.spezi.modules.storage.di import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import edu.stanford.spezi.modules.storage.key.EncryptedKeyValueStorage import edu.stanford.spezi.modules.storage.key.KeyValueStorage +import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory +import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactoryImpl +import edu.stanford.spezi.modules.storage.key.KeyValueStorageType +import edu.stanford.spezi.modules.storage.local.LocalStorage +import edu.stanford.spezi.modules.storage.local.LocalStorageImpl +import edu.stanford.spezi.modules.storage.secure.CredentialStorage +import edu.stanford.spezi.modules.storage.secure.CredentialStorageImpl +import edu.stanford.spezi.modules.storage.secure.KeyStorage +import edu.stanford.spezi.modules.storage.secure.KeyStorageImpl +import edu.stanford.spezi.modules.storage.secure.SecureStorage +import edu.stanford.spezi.modules.storage.secure.SecureStorageImpl +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -abstract class StorageModule { +class StorageModule { - @Binds + @Singleton + @Provides @Storage.Encrypted - abstract fun bindLocalKeyValueStorage( - encryptedKeyValueStorage: EncryptedKeyValueStorage, - ): KeyValueStorage + fun provideDefaultEncryptedKeyValueStorage( + keyValueStorageFactory: KeyValueStorageFactory, + ): KeyValueStorage { + return createDefaultKeyValueStorage( + keyValueStorageFactory = keyValueStorageFactory, + type = KeyValueStorageType.ENCRYPTED, + ) + } + + @Singleton + @Provides + @Storage.Unencrypted + fun provideDefaultUnEncryptedKeyValueStorage( + keyValueStorageFactory: KeyValueStorageFactory, + ): KeyValueStorage { + return createDefaultKeyValueStorage( + keyValueStorageFactory = keyValueStorageFactory, + type = KeyValueStorageType.UNENCRYPTED, + ) + } + + private fun createDefaultKeyValueStorage( + keyValueStorageFactory: KeyValueStorageFactory, + type: KeyValueStorageType, + ): KeyValueStorage { + return keyValueStorageFactory.create( + fileName = "${Storage.STORAGE_FILE_PREFIX}${type.name}", + type = type + ) + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Bindings { + @Binds + internal abstract fun bindKeyValueStorageFactory( + impl: KeyValueStorageFactoryImpl, + ): KeyValueStorageFactory + + @Binds + internal abstract fun bindCredentialStorage( + impl: CredentialStorageImpl, + ): CredentialStorage + + @Binds + internal abstract fun bindKeyStorage( + impl: KeyStorageImpl, + ): KeyStorage + + @Binds + internal abstract fun bindSecureStorage( + impl: SecureStorageImpl, + ): SecureStorage + + @Binds + internal abstract fun bindLocalStorage( + impl: LocalStorageImpl, + ): LocalStorage + } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt deleted file mode 100644 index 40d1d6c60..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt +++ /dev/null @@ -1,65 +0,0 @@ -package edu.stanford.spezi.modules.storage.file - -import android.content.Context -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.MasterKey -import dagger.hilt.android.qualifiers.ApplicationContext -import edu.stanford.spezi.core.coroutines.di.Dispatching -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import java.io.File -import javax.inject.Inject - -class EncryptedFileStorage @Inject constructor( - @ApplicationContext private val context: Context, - @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, -) : - FileStorage { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private fun getEncryptedFile(fileName: String): EncryptedFile { - val file = File(context.filesDir, fileName) - - return EncryptedFile.Builder( - context, - file, - masterKey, - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB - ).build() - } - - override suspend fun saveFile(fileName: String, data: ByteArray): Result = - withContext(ioDispatcher) { - runCatching { - deleteFile(fileName) - val encryptedFile = getEncryptedFile(fileName) - encryptedFile.openFileOutput().use { outputStream -> - outputStream.write(data) - } - } - } - - override suspend fun readFile(fileName: String): Result = - withContext(ioDispatcher) { - runCatching { - val file = File(context.filesDir, fileName) - if (!file.exists()) return@runCatching null - - val encryptedFile = getEncryptedFile(fileName) - encryptedFile.openFileInput().use { inputStream -> - inputStream.readBytes() - } - } - } - - override suspend fun deleteFile(fileName: String): Result = withContext(ioDispatcher) { - runCatching { - val file = File(context.filesDir, fileName) - if (file.exists()) { - file.delete() - } - } - } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt deleted file mode 100644 index 236925487..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt +++ /dev/null @@ -1,7 +0,0 @@ -package edu.stanford.spezi.modules.storage.file - -interface FileStorage { - suspend fun readFile(fileName: String): Result - suspend fun deleteFile(fileName: String): Result - suspend fun saveFile(fileName: String, data: ByteArray): Result -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorage.kt deleted file mode 100644 index ea7faf08a..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorage.kt +++ /dev/null @@ -1,116 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import android.content.Context -import android.content.SharedPreferences -import android.util.Base64 -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import dagger.hilt.android.qualifiers.ApplicationContext -import edu.stanford.spezi.core.coroutines.di.Dispatching -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@Suppress("TooManyFunctions") -class EncryptedKeyValueStorage @Inject constructor( - @ApplicationContext private val context: Context, - @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, -) : KeyValueStorage { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val sharedPreferences: SharedPreferences = - EncryptedSharedPreferences.create( - context, - "spezi_shared_preferences", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - override suspend fun getString(key: String, default: String): String { - return execute { sharedPreferences.getString(key, default) ?: default } - } - - override suspend fun putString(key: String, value: String) { - execute { sharedPreferences.edit { putString(key, value) } } - } - - override suspend fun deleteString(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getBoolean(key: String, default: Boolean): Boolean { - return execute { sharedPreferences.getBoolean(key, default) } - } - - override suspend fun putBoolean(key: String, value: Boolean) { - execute { sharedPreferences.edit { putBoolean(key, value) } } - } - - override suspend fun deleteBoolean(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getLong(key: String, default: Long): Long { - return execute { sharedPreferences.getLong(key, default) } - } - - override suspend fun putLong(key: String, value: Long) { - execute { sharedPreferences.edit { putLong(key, value) } } - } - - override suspend fun deleteLong(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getInt(key: String, default: Int): Int { - return execute { sharedPreferences.getInt(key, default) } - } - - override suspend fun putInt(key: String, value: Int) { - execute { sharedPreferences.edit { putInt(key, value) } } - } - - override suspend fun deleteInt(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getFloat(key: String, default: Float): Float { - return execute { sharedPreferences.getFloat(key, default) } - } - - override suspend fun putFloat(key: String, value: Float) { - execute { sharedPreferences.edit { putFloat(key, value) } } - } - - override suspend fun deleteFloat(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getByteArray(key: String, default: ByteArray): ByteArray { - return execute { - val encoded = sharedPreferences.getString(key, null) - encoded?.let { Base64.decode(it, Base64.DEFAULT) } ?: default - } - } - - override suspend fun clear() { - sharedPreferences.edit { clear() } - } - - override suspend fun putByteArray(key: String, value: ByteArray) { - execute { - val encoded = Base64.encodeToString(value, Base64.DEFAULT) - sharedPreferences.edit { putString(key, encoded) } - } - } - - override suspend fun deleteByteArray(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - private suspend fun execute(block: suspend () -> T) = withContext(ioDispatcher) { block() } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt index baec5c461..0d66d7b6c 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt @@ -7,79 +7,71 @@ import javax.inject.Inject class InMemoryKeyValueStorage @Inject constructor() : KeyValueStorage { private val storage = ConcurrentHashMap() - override suspend fun getString(key: String, default: String): String { - return getValue(key) as? String ?: default + override fun getString(key: String): String? { + return getValue(key) as? String } - override suspend fun putString(key: String, value: String) { - putValue(key, value) + override fun getString(key: String, default: String): String { + return getValue(key) as? String ?: default } - override suspend fun deleteString(key: String) { - remove(key) + override fun putString(key: String, value: String) { + putValue(key, value) } - override suspend fun getBoolean(key: String, default: Boolean): Boolean { + override fun getBoolean(key: String, default: Boolean): Boolean { return getValue(key) as? Boolean ?: default } - override suspend fun putBoolean(key: String, value: Boolean) { + override fun putBoolean(key: String, value: Boolean) { putValue(key, value) } - override suspend fun deleteBoolean(key: String) { - remove(key) - } - - override suspend fun getLong(key: String, default: Long): Long { + override fun getLong(key: String, default: Long): Long { return getValue(key) as? Long ?: default } - override suspend fun putLong(key: String, value: Long) { + override fun putLong(key: String, value: Long) { putValue(key, value) } - override suspend fun deleteLong(key: String) { - remove(key) - } - - override suspend fun getInt(key: String, default: Int): Int { + override fun getInt(key: String, default: Int): Int { return getValue(key) as? Int ?: default } - override suspend fun putInt(key: String, value: Int) { + override fun putInt(key: String, value: Int) { putValue(key, value) } - override suspend fun deleteInt(key: String) { - remove(key) - } - - override suspend fun getFloat(key: String, default: Float): Float { + override fun getFloat(key: String, default: Float): Float { return getValue(key) as? Float ?: default } - override suspend fun putFloat(key: String, value: Float) { + override fun putFloat(key: String, value: Float) { putValue(key, value) } - override suspend fun deleteFloat(key: String) { - remove(key) + override fun getByteArray(key: String): ByteArray? { + return getValue(key) as? ByteArray } - override suspend fun getByteArray(key: String, default: ByteArray): ByteArray { - return getValue(key) as? ByteArray ?: default + override fun getByteArray(key: String, default: ByteArray): ByteArray { + return getByteArray(key) ?: default } - override suspend fun putByteArray(key: String, value: ByteArray) { + override fun putByteArray(key: String, value: ByteArray) { putValue(key, value) } - override suspend fun deleteByteArray(key: String) { - remove(key) + override fun allKeys(): Set { + return storage.keys + } + + override fun delete(key: String) { + storage.remove(key) } - override suspend fun clear() { + override fun clear() { storage.clear() } @@ -88,8 +80,4 @@ class InMemoryKeyValueStorage @Inject constructor() : KeyValueStorage { } fun getValue(key: String): Any? = storage[key] - - private fun remove(key: String) { - storage.remove(key) - } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt index 214f6c310..3aa89e5f0 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt @@ -1,30 +1,76 @@ package edu.stanford.spezi.modules.storage.key +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + @Suppress("TooManyFunctions") -interface KeyValueStorage { - suspend fun getString(key: String, default: String): String - suspend fun putString(key: String, value: String) - suspend fun deleteString(key: String) +sealed interface KeyValueStorage { + + fun getString(key: String): String? + fun getString(key: String, default: String): String + fun putString(key: String, value: String) + + fun getBoolean(key: String, default: Boolean): Boolean + fun putBoolean(key: String, value: Boolean) - suspend fun getBoolean(key: String, default: Boolean): Boolean - suspend fun putBoolean(key: String, value: Boolean) - suspend fun deleteBoolean(key: String) + fun getLong(key: String, default: Long): Long + fun putLong(key: String, value: Long) - suspend fun getLong(key: String, default: Long): Long - suspend fun putLong(key: String, value: Long) - suspend fun deleteLong(key: String) + fun getInt(key: String, default: Int): Int + fun putInt(key: String, value: Int) - suspend fun getInt(key: String, default: Int): Int - suspend fun putInt(key: String, value: Int) - suspend fun deleteInt(key: String) + fun getFloat(key: String, default: Float): Float + fun putFloat(key: String, value: Float) - suspend fun getFloat(key: String, default: Float): Float - suspend fun putFloat(key: String, value: Float) - suspend fun deleteFloat(key: String) + fun getByteArray(key: String): ByteArray? + fun getByteArray(key: String, default: ByteArray): ByteArray + fun putByteArray(key: String, value: ByteArray) - suspend fun getByteArray(key: String, default: ByteArray): ByteArray - suspend fun putByteArray(key: String, value: ByteArray) - suspend fun deleteByteArray(key: String) + fun allKeys(): Set - suspend fun clear() + fun delete(key: String) + + fun clear() } + +inline fun KeyValueStorage.getSerializable(key: String): T? = + when (this) { + is KeyValueStorageImpl -> { + val jsonString = getString(key, "") + runCatching { + Json.decodeFromString(serializer(), jsonString) + }.getOrNull() + } + + is InMemoryKeyValueStorage -> getValue(key) as? T + } + +inline fun KeyValueStorage.putSerializable(key: String, value: T) { + when (this) { + is KeyValueStorageImpl -> { + runCatching { + putString(key = key, Json.encodeToString(value)) + } + } + is InMemoryKeyValueStorage -> putValue(key, value) + } +} + +inline fun KeyValueStorage.getSerializable(key: String, default: T): T = + getSerializable(key) ?: default + +inline fun KeyValueStorage.getSerializableList( + key: String, +): List = + when (this) { + is KeyValueStorageImpl -> { + val jsonString = getString(key, "") + runCatching { + Json.decodeFromString(ListSerializer(serializer()), jsonString) + }.getOrNull() ?: emptyList() + } + + is InMemoryKeyValueStorage -> getSerializable>(key, emptyList()) + } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt new file mode 100644 index 000000000..d78796ca1 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt @@ -0,0 +1,58 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +interface KeyValueStorageFactory { + fun create( + fileName: String, + type: KeyValueStorageType, + ): KeyValueStorage +} + +@Singleton +internal class KeyValueStorageFactoryImpl @Inject constructor( + private val storageFactory: KeyValueStorageImpl.Factory, + @ApplicationContext private val context: Context, +) : KeyValueStorageFactory { + + private val masterKey: MasterKey by lazy { + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + override fun create(fileName: String, type: KeyValueStorageType): KeyValueStorage { + val preferences = createSharedPreferences(fileName = fileName, type = type) + return storageFactory.create(preferences) + } + + private fun createSharedPreferences( + fileName: String, + type: KeyValueStorageType, + ): Lazy { + return lazy { + when (type) { + KeyValueStorageType.UNENCRYPTED -> context.getSharedPreferences( + fileName, + Context.MODE_PRIVATE + ) + + KeyValueStorageType.ENCRYPTED -> { + EncryptedSharedPreferences.create( + context, + fileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + } + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt new file mode 100644 index 000000000..e833ce209 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt @@ -0,0 +1,83 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.SharedPreferences +import android.util.Base64 +import androidx.core.content.edit +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("TooManyFunctions") +class KeyValueStorageImpl @AssistedInject internal constructor( + @Assisted preferences: Lazy, +) : KeyValueStorage { + + private val sharedPreferences by preferences + + override fun allKeys(): Set = sharedPreferences.all.keys + + override fun getString(key: String): String? = sharedPreferences.getString(key, null) + + override fun getString(key: String, default: String): String = + sharedPreferences.getString(key, default) ?: default + + override fun putString(key: String, value: String) { + sharedPreferences.edit { putString(key, value) } + } + + override fun getBoolean(key: String, default: Boolean): Boolean = + sharedPreferences.getBoolean(key, default) + + override fun putBoolean(key: String, value: Boolean) { + sharedPreferences.edit { putBoolean(key, value) } + } + + override fun getLong(key: String, default: Long): Long = + sharedPreferences.getLong(key, default) + + override fun putLong(key: String, value: Long) { + sharedPreferences.edit { putLong(key, value) } + } + + override fun getInt(key: String, default: Int): Int = sharedPreferences.getInt(key, default) + + override fun putInt(key: String, value: Int) { + sharedPreferences.edit { putInt(key, value) } + } + + override fun getFloat(key: String, default: Float): Float = + sharedPreferences.getFloat(key, default) + + override fun putFloat(key: String, value: Float) { + sharedPreferences.edit { putFloat(key, value) } + } + + override fun getByteArray(key: String): ByteArray? = runCatching { + val encoded = sharedPreferences.getString(key, null) + encoded?.let { Base64.decode(it, Base64.DEFAULT) } + }.getOrNull() + + override fun getByteArray(key: String, default: ByteArray): ByteArray { + return getByteArray(key) ?: default + } + + override fun delete(key: String) { + sharedPreferences.edit { remove(key) } + } + + override fun clear() { + sharedPreferences.edit { clear() } + } + + override fun putByteArray(key: String, value: ByteArray) { + val encoded = Base64.encodeToString(value, Base64.DEFAULT) + sharedPreferences.edit { putString(key, encoded) } + } + + @AssistedFactory + internal interface Factory { + fun create( + preferences: Lazy, + ): KeyValueStorageImpl + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt deleted file mode 100644 index c93d2e9e2..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt +++ /dev/null @@ -1,51 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer - -suspend inline fun KeyValueStorage.getSerializable(key: String): T? = - when (this) { - is EncryptedKeyValueStorage, is LocalKeyValueStorage -> { - val jsonString = getString(key, "") - runCatching { - Json.decodeFromString(serializer(), jsonString) - }.getOrNull() - } - - is InMemoryKeyValueStorage -> getValue(key) as? T - else -> null - } - -suspend inline fun KeyValueStorage.putSerializable(key: String, value: T) { - when (this) { - is EncryptedKeyValueStorage, is LocalKeyValueStorage -> { - runCatching { - putString(key = key, Json.encodeToString(value)) - } - } - is InMemoryKeyValueStorage -> putValue(key, value) - } -} - -suspend inline fun KeyValueStorage.getSerializable(key: String, default: T): T = - getSerializable(key) ?: default - -suspend inline fun KeyValueStorage.deleteSerializable(key: String) = - deleteString(key) - -suspend inline fun KeyValueStorage.getSerializableList( - key: String, -): List = - when (this) { - is EncryptedKeyValueStorage, is LocalKeyValueStorage -> { - val jsonString = getString(key, "") - runCatching { - Json.decodeFromString(ListSerializer(serializer()), jsonString) - }.getOrNull() ?: emptyList() - } - - is InMemoryKeyValueStorage -> getSerializable>(key, emptyList()) - else -> emptyList() - } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt new file mode 100644 index 000000000..2d78d640a --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt @@ -0,0 +1,6 @@ +package edu.stanford.spezi.modules.storage.key + +enum class KeyValueStorageType { + ENCRYPTED, + UNENCRYPTED, +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt deleted file mode 100644 index fc68f8dae..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt +++ /dev/null @@ -1,128 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.byteArrayPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.firstOrNull -import javax.inject.Inject - -@Suppress("TooManyFunctions") -class LocalKeyValueStorage @Inject constructor( - @ApplicationContext private val context: Context, -) : KeyValueStorage { - companion object { - const val FILE_NAME = "spezi_preferences" - private val Context.dataStore: DataStore by preferencesDataStore( - name = FILE_NAME - ) - } - - private val dataStore = context.dataStore - - override suspend fun getString(key: String, default: String): String { - return getData(stringPreferencesKey(key)) ?: default - } - - override suspend fun putString(key: String, value: String) { - saveData(stringPreferencesKey(key), value) - } - - override suspend fun deleteString(key: String) { - deleteData(stringPreferencesKey(key)) - } - - override suspend fun getBoolean(key: String, default: Boolean): Boolean { - return getData(booleanPreferencesKey(key)) ?: default - } - - override suspend fun putBoolean(key: String, value: Boolean) { - saveData(booleanPreferencesKey(key), value) - } - - override suspend fun deleteBoolean(key: String) { - deleteData(booleanPreferencesKey(key)) - } - - override suspend fun getLong(key: String, default: Long): Long { - return getData(longPreferencesKey(key)) ?: default - } - - override suspend fun putLong(key: String, value: Long) { - saveData(longPreferencesKey(key), value) - } - - override suspend fun deleteLong(key: String) { - deleteData(longPreferencesKey(key)) - } - - override suspend fun getInt(key: String, default: Int): Int { - return getData(intPreferencesKey(key)) ?: default - } - - override suspend fun putInt(key: String, value: Int) { - saveData(intPreferencesKey(key), value) - } - - override suspend fun deleteInt(key: String) { - deleteData(intPreferencesKey(key)) - } - - override suspend fun getFloat(key: String, default: Float): Float { - return getData(floatPreferencesKey(key)) ?: default - } - - override suspend fun putFloat(key: String, value: Float) { - saveData(floatPreferencesKey(key), value) - } - - override suspend fun deleteFloat(key: String) { - deleteData(floatPreferencesKey(key)) - } - - override suspend fun getByteArray(key: String, default: ByteArray): ByteArray { - return getData(byteArrayPreferencesKey(key)) ?: default - } - - override suspend fun putByteArray(key: String, value: ByteArray) { - saveData(byteArrayPreferencesKey(key), value) - } - - override suspend fun deleteByteArray(key: String) { - deleteData(byteArrayPreferencesKey(key)) - } - - override suspend fun clear() { - context.dataStore.edit { preferences -> preferences.clear() } - } - - private suspend fun saveData(key: Preferences.Key, data: T) { - dataStore.edit { preferences -> - preferences[key] = data - } - } - - private suspend fun getData(key: Preferences.Key): T? { - return runCatching { - dataStore.data - .catch { emit(emptyPreferences()) } - .firstOrNull()?.get(key) - }.getOrNull() - } - - private suspend fun deleteData(key: Preferences.Key) { - dataStore.edit { preferences -> - preferences.remove(key) - } - } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index 10feb913a..bf88e1bb5 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -2,88 +2,166 @@ package edu.stanford.spezi.modules.storage.local import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext -import edu.stanford.spezi.modules.storage.secure.SecureStorage +import edu.stanford.spezi.core.coroutines.di.Dispatching +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.modules.storage.di.Storage +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.Encrypted +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.EncryptedUsingKeyStore +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.Unencrypted +import edu.stanford.spezi.modules.storage.secure.KeyStorage +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json import java.io.File +import java.nio.charset.StandardCharsets import java.security.Key +import java.security.KeyPair import javax.crypto.Cipher import javax.inject.Inject -import kotlin.reflect.KClass -class LocalStorage @Inject constructor( - @ApplicationContext val context: Context, -) { - private val secureStorage = SecureStorage(context) +interface LocalStorage { - private fun createCipher(mode: Int, key: Key): Cipher = - // TODO: Supported values: https://developer.android.com/reference/kotlin/javax/crypto/Cipher - Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding").apply { init(mode, key) } + suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + serializer: SerializationStrategy, + ) - fun store(storageKey: String, data: ByteArray, settings: LocalStorageSetting) = - store(file(storageKey, ByteArray::class), data, settings) + suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + encoding: (T) -> ByteArray, + ) - fun store( - value: C, - storageKey: String? = null, - type: KClass, + suspend fun read( + key: String, settings: LocalStorageSetting, - encode: (C) -> ByteArray, - ) = store(file(storageKey, type), encode(value), settings) + serializer: DeserializationStrategy, + ): T? - private fun store( - file: File, - data: ByteArray, + suspend fun read( + key: String, settings: LocalStorageSetting, + decoding: (ByteArray) -> T, + ): T? + + suspend fun delete(key: String) +} + +internal class LocalStorageImpl @Inject constructor( + @ApplicationContext private val context: Context, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, + private val keyStorage: KeyStorage, +) : LocalStorage { + + private val logger by speziLogger() + + override suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + serializer: SerializationStrategy, ) { - val keys = settings.keys(secureStorage) ?: run { - file.writeBytes(data) - return - } - val encryptedData = createCipher(Cipher.ENCRYPT_MODE, keys.public) - .doFinal(data) - file.writeBytes(encryptedData) + store( + key = key, + value = value, + settings = settings, + encoding = { instance -> + Json.encodeToString(serializer, instance).toByteArray(StandardCharsets.UTF_8) + } + ) } - fun read( - storageKey: String? = null, - type: KClass, + override suspend fun store( + key: String, + value: T, settings: LocalStorageSetting, - decode: (ByteArray) -> C, - ): C = read(file(storageKey, type), settings, decode) - - fun read(storageKey: String, settings: LocalStorageSetting): ByteArray = - read(file(storageKey, ByteArray::class), settings) { it } + encoding: (T) -> ByteArray, + ) { + execute { + val jsonData = encoding(value) + val keys = keys(settings) + val writeData = if (keys == null) { + jsonData + } else { + getInitializedCipher(Cipher.ENCRYPT_MODE, keys.public).doFinal(jsonData) + } + file(key).writeBytes(writeData) + } + } - private fun read( - file: File, + override suspend fun read( + key: String, settings: LocalStorageSetting, - decode: (ByteArray) -> C, - ): C { - val keys = settings.keys(secureStorage = secureStorage) - ?: return decode(file.readBytes()) - val data = createCipher(Cipher.DECRYPT_MODE, keys.private) - .doFinal(file.readBytes()) - return decode(data) + serializer: DeserializationStrategy, + ): T? { + return read( + key = key, + settings = settings, + decoding = { data -> + Json.decodeFromString(serializer, String(data, StandardCharsets.UTF_8)) + } + ) } - fun delete(storageKey: String) = delete(file(storageKey, String::class)) - - fun delete(type: KClass) = delete(file(null, type)) + override suspend fun read( + key: String, + settings: LocalStorageSetting, + decoding: (ByteArray) -> T, + ): T? = execute { + val keys = keys(settings) + val bytes = file(key).readBytes() + val data = if (keys == null) { + bytes + } else { + getInitializedCipher(Cipher.DECRYPT_MODE, keys.private).doFinal(bytes) + } + decoding(data) + } - private fun delete(file: File) { - if (file.exists()) { - file.delete() + override suspend fun delete(key: String) { + execute { + val file = file(key) + if (file.exists()) { + file.delete() + } } } - private fun file(storageKey: String?, type: KClass<*>): File { - val filename = storageKey - ?: type.qualifiedName - ?: type.simpleName - ?: throw LocalStorageError.FileNameCouldNotBeIdentified - val directory = File(context.filesDir, "edu.stanford.spezi/LocalStorage") + private fun file(key: String): File { + val directory = File(context.filesDir, "${Storage.STORAGE_FILE_PREFIX}LocalStorage") if (!directory.exists()) { directory.mkdirs() } - return File(context.filesDir, "edu.stanford.spezi/LocalStorage/$filename.localstorage") + return File(directory, "$key.localstorage") + } + + private fun keys(settings: LocalStorageSetting): KeyPair? { + return when (settings) { + is Unencrypted -> null + is Encrypted -> settings.keyPair + is EncryptedUsingKeyStore -> with(keyStorage) { + retrieveKeyPair(ANDROID_KEYSTORE_TAG) + ?: create(ANDROID_KEYSTORE_TAG).getOrThrow() + } + } + } + + private fun getInitializedCipher(mode: Int, key: Key): Cipher = + Cipher.getInstance(KeyStorage.CIPHER_TRANSFORMATION).apply { init(mode, key) } + + private suspend fun execute(block: suspend () -> T) = withContext(ioDispatcher) { + runCatching { block() } + .onFailure { + logger.e(it) { "Error executing local storage operation" } + }.getOrNull() + } + + private companion object { + const val ANDROID_KEYSTORE_TAG = "LocalStorageTag" } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt deleted file mode 100644 index d161083a6..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageError.kt +++ /dev/null @@ -1,8 +0,0 @@ -package edu.stanford.spezi.modules.storage.local - -sealed class LocalStorageError : Exception() { - data object FileNameCouldNotBeIdentified : LocalStorageError() { - @Suppress("detekt:UnusedPrivateMember") - private fun readResolve(): Any = FileNameCouldNotBeIdentified - } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt index e75c99f24..fa99a2dc9 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -1,38 +1,9 @@ package edu.stanford.spezi.modules.storage.local -import edu.stanford.spezi.core.logging.speziLogger -import edu.stanford.spezi.modules.storage.secure.SecureStorage -import edu.stanford.spezi.modules.storage.secure.SecureStorageScope import java.security.KeyPair -sealed class LocalStorageSetting { - data object Unencrypted : LocalStorageSetting() - - data class Encrypted(val keyPair: KeyPair) : LocalStorageSetting() - - data object EncryptedUsingKeyStore : LocalStorageSetting() { - val logger by speziLogger() - } - - fun keys(secureStorage: SecureStorage): KeyPair? { - return when (this) { - is Unencrypted -> null - is Encrypted -> keyPair - is EncryptedUsingKeyStore -> { - val identifier = SecureStorageScope.KeyStore.identifier - val tag = "LocalStorage.$identifier" - return runCatching { - val privateKey = secureStorage.retrievePrivateKey(tag) - val publicKey = secureStorage.retrievePublicKey(tag) - return if (privateKey != null && publicKey != null) { - KeyPair(publicKey, privateKey) - } else { - null - } - }.onFailure { failure -> - logger.e(failure) { "Retrieving private or public key from SecureStorage failed" } - }.getOrNull() ?: secureStorage.createKey(tag) - } - } - } +sealed interface LocalStorageSetting { + data object Unencrypted : LocalStorageSetting + data class Encrypted(val keyPair: KeyPair) : LocalStorageSetting + data object EncryptedUsingKeyStore : LocalStorageSetting } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt new file mode 100644 index 000000000..8ff716deb --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt @@ -0,0 +1,10 @@ +package edu.stanford.spezi.modules.storage.secure + +import kotlinx.serialization.Serializable + +@Serializable +data class Credential( + val username: String, + val password: String, + val server: String? = null, +) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt new file mode 100644 index 000000000..6f529b706 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt @@ -0,0 +1,97 @@ +package edu.stanford.spezi.modules.storage.secure + +import edu.stanford.spezi.modules.storage.di.Storage +import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory +import edu.stanford.spezi.modules.storage.key.KeyValueStorageType +import edu.stanford.spezi.modules.storage.key.getSerializable +import edu.stanford.spezi.modules.storage.key.putSerializable +import java.util.EnumSet +import javax.inject.Inject + +interface CredentialStorage { + fun store(credential: Credential) + + fun update( + username: String, + server: String? = null, + newCredential: Credential, + ) + + fun retrieve(username: String, server: String? = null): Credential? + fun retrieveAll(server: String? = null): List + + fun delete(username: String, server: String? = null) + fun deleteAll(types: EnumSet) +} + +internal class CredentialStorageImpl @Inject constructor( + storageFactory: KeyValueStorageFactory, +) : CredentialStorage { + + private val storage = storageFactory.create( + fileName = SECURE_STORAGE_FILE_NAME, + type = KeyValueStorageType.ENCRYPTED, + ) + + override fun store(credential: Credential) { + storage.putSerializable( + key = storageKey(credential.server, credential.username), + value = credential + ) + } + + override fun retrieve( + username: String, + server: String?, + ): Credential? { + return storage.getSerializable(storageKey(server, username)) + } + + override fun retrieveAll(server: String?): List { + return storage.allKeys().mapNotNull { key -> + if (key.startsWith(storageKey(server, ""))) { + storage.getSerializable(key) + } else { + null + } + } + } + + override fun delete( + username: String, + server: String?, + ) { + storage.delete(key = storageKey(server, username)) + } + + override fun deleteAll(types: EnumSet) { + if (types.isEmpty()) return + if (types == CredentialType.ALL) return storage.clear() + val deleteServer = types.contains(CredentialType.SERVER) + val deleteNonServer = types.contains(CredentialType.NON_SERVER) + storage.allKeys().forEach { key -> + val isServerKey = key.substringBefore(SERVER_USERNAME_SEPARATOR).isNotEmpty() + when { + isServerKey && deleteServer -> storage.delete(key) + !isServerKey && deleteNonServer -> storage.delete(key) + } + } + } + + override fun update( + username: String, + server: String?, + newCredential: Credential, + ) { + delete(username, server) + store(newCredential) + } + + private fun storageKey(server: String?, username: String): String = + "${server ?: ""}$SERVER_USERNAME_SEPARATOR$username" + + private companion object { + const val SECURE_STORAGE_FILE_NAME = "${Storage.STORAGE_FILE_PREFIX}SecureStorage" + const val SERVER_USERNAME_SEPARATOR = "__@__" + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt new file mode 100644 index 000000000..6e83fdca0 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt @@ -0,0 +1,11 @@ +package edu.stanford.spezi.modules.storage.secure + +import java.util.EnumSet + +enum class CredentialType { + SERVER, NON_SERVER; + + companion object { + val ALL: EnumSet = EnumSet.allOf(CredentialType::class.java) + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt deleted file mode 100644 index 104e1726c..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credentials.kt +++ /dev/null @@ -1,6 +0,0 @@ -package edu.stanford.spezi.modules.storage.secure - -data class Credentials( - val username: String, - val password: String, -) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt new file mode 100644 index 000000000..6c5274fc3 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt @@ -0,0 +1,85 @@ +package edu.stanford.spezi.modules.storage.secure + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import edu.stanford.spezi.core.logging.speziLogger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.inject.Inject + +interface KeyStorage { + fun create(tag: String, size: Int = DEFAULT_KEY_SIZE): Result + + fun retrieveKeyPair(tag: String): KeyPair? + fun retrievePrivateKey(tag: String): PrivateKey? + fun retrievePublicKey(tag: String): PublicKey? + + fun delete(tag: String) + fun deleteAll() + + companion object { + internal const val DEFAULT_KEY_SIZE = 2048 + internal const val PROVIDER = "AndroidKeyStore" + const val CIPHER_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding" + } +} + +internal class KeyStorageImpl @Inject constructor() : KeyStorage { + private val logger by speziLogger() + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(KeyStorage.PROVIDER).apply { load(null) } + } + + override fun create(tag: String, size: Int): Result = runCatching { + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + tag, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_ECB) + .setKeySize(size) + .setDigests(KeyProperties.DIGEST_SHA1) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA) + keyPairGenerator.initialize(keyGenParameterSpec) + keyPairGenerator.genKeyPair() + } + + override fun retrieveKeyPair(tag: String): KeyPair? = runCatching { + val publicKey = retrievePublicKey(tag) + val privateKey = retrievePrivateKey(tag) + if (publicKey != null && privateKey != null) { + KeyPair(publicKey, privateKey) + } else { + null + } + }.getOrNull() + + override fun retrievePrivateKey(tag: String): PrivateKey? = runCatching { + keyStore.getKey(tag, null) as? PrivateKey + }.onFailure { + logger.e(it) { "Failure during retrieval of private key with $tag" } + }.getOrNull() + + override fun retrievePublicKey(tag: String): PublicKey? = runCatching { + keyStore.getCertificate(tag)?.publicKey + }.onFailure { + logger.e(it) { "Failure during retrieval of public key with $tag" } + }.getOrNull() + + override fun delete(tag: String) = runCatching { + keyStore.deleteEntry(tag) + }.onFailure { + logger.e(it) { "Failed to delete entry with $tag" } + }.getOrDefault(Unit) + + override fun deleteAll() { + for (tag in keyStore.aliases()) { + delete(tag) + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt index f5601c510..ea97e0175 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt @@ -1,147 +1,68 @@ package edu.stanford.spezi.modules.storage.secure -import android.content.Context -import android.content.SharedPreferences -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.spezi.core.logging.speziLogger import java.security.KeyPair -import java.security.KeyPairGenerator -import java.security.KeyStore import java.security.PrivateKey import java.security.PublicKey +import java.util.EnumSet +import javax.inject.Inject -class SecureStorage( - @ApplicationContext val context: Context, -) { - private val provider = "AndroidKeyStore" - private val keyStore: KeyStore = KeyStore.getInstance(provider).apply { load(null) } - private val preferencesKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - private val preferences: SharedPreferences = EncryptedSharedPreferences.create( - context, - "Spezi_SecureStoragePrefs", - preferencesKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - fun createKey( - tag: String, - size: Int = 2048, // TODO: Should we just use RSA here instead of what iOS uses? - ): KeyPair { - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - tag, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setKeySize(size) - .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) - .build() - val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA) - keyPairGenerator.initialize(keyGenParameterSpec) - return keyPairGenerator.genKeyPair() - } - - fun retrievePrivateKey(tag: String): PrivateKey? { - return keyStore.getKey(tag, null) as? PrivateKey - } - - fun retrievePublicKey(tag: String): PublicKey? { - return keyStore.getCertificate(tag)?.publicKey - } - - fun deleteKeys(tag: String) { - keyStore.deleteEntry(tag) - } +interface SecureStorage { + fun createKey(tag: String, size: Int): Result + fun retrievePrivateKey(tag: String): PrivateKey? + fun retrievePublicKey(tag: String): PublicKey? + fun deleteKeyPair(tag: String) - fun store( - credentials: Credentials, - server: String? = null, - ) { - preferences.edit { - putString(sharedPreferencesKey(server, credentials.username), credentials.password) - } - } - - fun deleteCredentials( + fun storeCredential(credential: Credential) + fun updateCredential( username: String, server: String? = null, - ) { - val key = sharedPreferencesKey(server, username) - preferences.edit { remove(key) } - } + newCredential: Credential, + ) + fun retrieveCredential(username: String, server: String? = null): Credential? + fun retrieveAllCredentials(server: String? = null): List + fun deleteCredential(username: String, server: String? = null) + fun deleteAll( + includingKeys: Boolean = true, + credentialTypes: EnumSet = CredentialType.ALL, + ) +} - fun deleteAllCredentials(itemTypes: SecureStorageItemTypes) { - val containsServerCredentials = itemTypes.types.contains(SecureStorageItemType.SERVER_CREDENTIALS) - val containsNonServerCredentials = itemTypes.types.contains(SecureStorageItemType.NON_SERVER_CREDENTIALS) - if (containsServerCredentials || containsNonServerCredentials) { - preferences.edit { - preferences.all.forEach { - if (it.key.startsWith(" ")) { // non-server credential - if (containsNonServerCredentials) { - remove(it.key) - } - } else { - if (containsServerCredentials) { - remove(it.key) - } - } - } - } - } +internal class SecureStorageImpl @Inject constructor( + private val credentialStorage: CredentialStorage, + private val keyStorage: KeyStorage, +) : SecureStorage { + private val logger by speziLogger() - if (itemTypes.types.contains(SecureStorageItemType.KEYS)) { - for (tag in keyStore.aliases()) { - keyStore.deleteEntry(tag) - } - } - } + override fun createKey(tag: String, size: Int) = keyStorage.create(tag, size) + override fun retrievePrivateKey(tag: String) = keyStorage.retrievePrivateKey(tag) + override fun retrievePublicKey(tag: String) = keyStorage.retrievePublicKey(tag) + override fun deleteKeyPair(tag: String) = keyStorage.delete(tag) - fun updateCredentials( - username: String, - server: String? = null, - newCredentials: Credentials, - newServer: String? = null, - ) { - deleteCredentials(username, server) - store(newCredentials, newServer) - } + override fun storeCredential(credential: Credential) = + credentialStorage.store(credential) - fun retrieveCredentials( + override fun updateCredential( username: String, - server: String? = null, - ): Credentials? { - val key = sharedPreferencesKey(server, username) - return preferences.getString(key, null)?.let { - Credentials(username, it) - } - } + server: String?, + newCredential: Credential, + ) = credentialStorage.update(username, server, newCredential) - fun retrieveAllCredentials( - server: String? = null, - ): List { - return preferences.all.mapNotNull { entry -> - val password = server?.let { - if (entry.key.startsWith("$server ")) { - entry.value as? String - } else { - null - } - } ?: entry.value as? String + override fun retrieveCredential(username: String, server: String?) = + credentialStorage.retrieve(username, server) + override fun retrieveAllCredentials(server: String?) = + credentialStorage.retrieveAll(server) + + override fun deleteCredential(username: String, server: String?) = + credentialStorage.delete(username, server) - password?.let { - val separatorIndex = entry.key.indexOf(" ") - Credentials(entry.key.drop(separatorIndex + 1), password) - } + override fun deleteAll( + includingKeys: Boolean, + credentialTypes: EnumSet, + ) { + if (includingKeys) { + keyStorage.deleteAll() } + credentialStorage.deleteAll(credentialTypes) } - - // TODO: Check for potential key collisions - private fun sharedPreferencesKey(server: String?, username: String): String = - "${server ?: ""} $username" } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt deleted file mode 100644 index 8096e2411..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageItemTypes.kt +++ /dev/null @@ -1,38 +0,0 @@ -package edu.stanford.spezi.modules.storage.secure - -import java.util.EnumSet - -enum class SecureStorageItemType { - KEYS, - SERVER_CREDENTIALS, - NON_SERVER_CREDENTIALS, -} - -data class SecureStorageItemTypes(val types: EnumSet) { - companion object { - val keys = SecureStorageItemTypes( - EnumSet.of( - SecureStorageItemType.KEYS - ) - ) - val serverCredentials = SecureStorageItemTypes( - EnumSet.of( - SecureStorageItemType.SERVER_CREDENTIALS - ) - ) - val nonServerCredentials = SecureStorageItemTypes( - EnumSet.of( - SecureStorageItemType.NON_SERVER_CREDENTIALS - ) - ) - val credentials = SecureStorageItemTypes( - EnumSet.of( - SecureStorageItemType.SERVER_CREDENTIALS, - SecureStorageItemType.NON_SERVER_CREDENTIALS - ) - ) - val all = SecureStorageItemTypes( - EnumSet.allOf(SecureStorageItemType::class.java) - ) - } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt deleted file mode 100644 index efc8d5a2d..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorageScope.kt +++ /dev/null @@ -1,11 +0,0 @@ -package edu.stanford.spezi.modules.storage.secure - -sealed class SecureStorageScope { - data object KeyStore : SecureStorageScope() - - val identifier: String get() = - when (this) { - is KeyStore -> - "keyStore" - } -} From d57fdd65cd6d6bd4232aee98412068bf8af11e0e Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Mon, 21 Oct 2024 15:34:41 -0700 Subject: [PATCH 14/18] Remove SecureStorage altogether --- .../spezi/modules/storage/di/StorageModule.kt | 7 -- .../modules/storage/secure/SecureStorage.kt | 68 ------------------- 2 files changed, 75 deletions(-) delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt index bf7fa6971..dec561c08 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt @@ -15,8 +15,6 @@ import edu.stanford.spezi.modules.storage.secure.CredentialStorage import edu.stanford.spezi.modules.storage.secure.CredentialStorageImpl import edu.stanford.spezi.modules.storage.secure.KeyStorage import edu.stanford.spezi.modules.storage.secure.KeyStorageImpl -import edu.stanford.spezi.modules.storage.secure.SecureStorage -import edu.stanford.spezi.modules.storage.secure.SecureStorageImpl import javax.inject.Singleton @Module @@ -75,11 +73,6 @@ class StorageModule { impl: KeyStorageImpl, ): KeyStorage - @Binds - internal abstract fun bindSecureStorage( - impl: SecureStorageImpl, - ): SecureStorage - @Binds internal abstract fun bindLocalStorage( impl: LocalStorageImpl, diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt deleted file mode 100644 index ea97e0175..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/SecureStorage.kt +++ /dev/null @@ -1,68 +0,0 @@ -package edu.stanford.spezi.modules.storage.secure - -import edu.stanford.spezi.core.logging.speziLogger -import java.security.KeyPair -import java.security.PrivateKey -import java.security.PublicKey -import java.util.EnumSet -import javax.inject.Inject - -interface SecureStorage { - fun createKey(tag: String, size: Int): Result - fun retrievePrivateKey(tag: String): PrivateKey? - fun retrievePublicKey(tag: String): PublicKey? - fun deleteKeyPair(tag: String) - - fun storeCredential(credential: Credential) - fun updateCredential( - username: String, - server: String? = null, - newCredential: Credential, - ) - fun retrieveCredential(username: String, server: String? = null): Credential? - fun retrieveAllCredentials(server: String? = null): List - fun deleteCredential(username: String, server: String? = null) - fun deleteAll( - includingKeys: Boolean = true, - credentialTypes: EnumSet = CredentialType.ALL, - ) -} - -internal class SecureStorageImpl @Inject constructor( - private val credentialStorage: CredentialStorage, - private val keyStorage: KeyStorage, -) : SecureStorage { - private val logger by speziLogger() - - override fun createKey(tag: String, size: Int) = keyStorage.create(tag, size) - override fun retrievePrivateKey(tag: String) = keyStorage.retrievePrivateKey(tag) - override fun retrievePublicKey(tag: String) = keyStorage.retrievePublicKey(tag) - override fun deleteKeyPair(tag: String) = keyStorage.delete(tag) - - override fun storeCredential(credential: Credential) = - credentialStorage.store(credential) - - override fun updateCredential( - username: String, - server: String?, - newCredential: Credential, - ) = credentialStorage.update(username, server, newCredential) - - override fun retrieveCredential(username: String, server: String?) = - credentialStorage.retrieve(username, server) - override fun retrieveAllCredentials(server: String?) = - credentialStorage.retrieveAll(server) - - override fun deleteCredential(username: String, server: String?) = - credentialStorage.delete(username, server) - - override fun deleteAll( - includingKeys: Boolean, - credentialTypes: EnumSet, - ) { - if (includingKeys) { - keyStorage.deleteAll() - } - credentialStorage.deleteAll(credentialTypes) - } -} From 930f640b7de2935d1a3dc810f69e893c8f4cdffd Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Mon, 21 Oct 2024 15:49:21 -0700 Subject: [PATCH 15/18] Update --- .../modules/storage/secure/CredentialStorageTests.kt | 8 ++++---- .../spezi/modules/storage/secure/CredentialStorage.kt | 2 +- .../spezi/modules/storage/secure/CredentialType.kt | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt index cb6c4af03..c7ae8cb03 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt @@ -37,7 +37,7 @@ class CredentialStorageTests { @After fun tearDown() { - credentialStorage.deleteAll(CredentialType.ALL) + credentialStorage.deleteAll(CredentialType.All) } @Test @@ -149,7 +149,7 @@ class CredentialStorageTests { listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } // when - credentialStorage.deleteAll(EnumSet.of(CredentialType.SERVER)) + credentialStorage.deleteAll(CredentialType.Server) val storedServerCredential = credentialStorage.retrieve( username = serverCredential.username, ) @@ -168,7 +168,7 @@ class CredentialStorageTests { listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } // when - credentialStorage.deleteAll(EnumSet.of(CredentialType.NON_SERVER)) + credentialStorage.deleteAll(CredentialType.NonServer) val storedServerCredential = credentialStorage.retrieve( username = serverCredential.username, server = serverCredential.server, @@ -188,7 +188,7 @@ class CredentialStorageTests { listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } // when - credentialStorage.deleteAll(CredentialType.ALL) + credentialStorage.deleteAll(CredentialType.All) val storedServerCredential = credentialStorage.retrieve( username = serverCredential.username, ) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt index 6f529b706..914a8d7ba 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt @@ -66,7 +66,7 @@ internal class CredentialStorageImpl @Inject constructor( override fun deleteAll(types: EnumSet) { if (types.isEmpty()) return - if (types == CredentialType.ALL) return storage.clear() + if (types == CredentialType.All) return storage.clear() val deleteServer = types.contains(CredentialType.SERVER) val deleteNonServer = types.contains(CredentialType.NON_SERVER) storage.allKeys().forEach { key -> diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt index 6e83fdca0..4c0351175 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt @@ -6,6 +6,8 @@ enum class CredentialType { SERVER, NON_SERVER; companion object { - val ALL: EnumSet = EnumSet.allOf(CredentialType::class.java) + val All: EnumSet = EnumSet.allOf(CredentialType::class.java) + val Server = EnumSet.of(CredentialType.SERVER) + val NonServer = EnumSet.of(CredentialType.NON_SERVER) } } From 9919af8ea7109ba075f850c617d2c971948ac569 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Mon, 21 Oct 2024 16:00:47 -0700 Subject: [PATCH 16/18] Wrap enumset into data class to make it more accessible --- .../storage/secure/CredentialStorage.kt | 13 ++++++------- .../modules/storage/secure/CredentialType.kt | 13 ------------- .../modules/storage/secure/CredentialTypes.kt | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 20 deletions(-) delete mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt create mode 100644 modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt index 914a8d7ba..7c81e7345 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt @@ -5,7 +5,6 @@ import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory import edu.stanford.spezi.modules.storage.key.KeyValueStorageType import edu.stanford.spezi.modules.storage.key.getSerializable import edu.stanford.spezi.modules.storage.key.putSerializable -import java.util.EnumSet import javax.inject.Inject interface CredentialStorage { @@ -21,7 +20,7 @@ interface CredentialStorage { fun retrieveAll(server: String? = null): List fun delete(username: String, server: String? = null) - fun deleteAll(types: EnumSet) + fun deleteAll(types: CredentialTypes) } internal class CredentialStorageImpl @Inject constructor( @@ -64,11 +63,11 @@ internal class CredentialStorageImpl @Inject constructor( storage.delete(key = storageKey(server, username)) } - override fun deleteAll(types: EnumSet) { - if (types.isEmpty()) return - if (types == CredentialType.All) return storage.clear() - val deleteServer = types.contains(CredentialType.SERVER) - val deleteNonServer = types.contains(CredentialType.NON_SERVER) + override fun deleteAll(types: CredentialTypes) { + if (types.set.isEmpty()) return + if (types.set == CredentialTypes.All.set) return storage.clear() + val deleteServer = types.set.contains(CredentialType.SERVER) + val deleteNonServer = types.set.contains(CredentialType.NON_SERVER) storage.allKeys().forEach { key -> val isServerKey = key.substringBefore(SERVER_USERNAME_SEPARATOR).isNotEmpty() when { diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt deleted file mode 100644 index 4c0351175..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialType.kt +++ /dev/null @@ -1,13 +0,0 @@ -package edu.stanford.spezi.modules.storage.secure - -import java.util.EnumSet - -enum class CredentialType { - SERVER, NON_SERVER; - - companion object { - val All: EnumSet = EnumSet.allOf(CredentialType::class.java) - val Server = EnumSet.of(CredentialType.SERVER) - val NonServer = EnumSet.of(CredentialType.NON_SERVER) - } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt new file mode 100644 index 000000000..253770c2a --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt @@ -0,0 +1,19 @@ +package edu.stanford.spezi.modules.storage.secure + +import edu.stanford.spezi.modules.storage.secure.CredentialType.NON_SERVER +import edu.stanford.spezi.modules.storage.secure.CredentialType.SERVER +import java.util.EnumSet + +data class CredentialTypes( + internal val set: EnumSet +) { + companion object { + val All = CredentialTypes(EnumSet.allOf(CredentialType::class.java)) + val Server = CredentialTypes(EnumSet.of(SERVER)) + val NonServer = CredentialTypes(EnumSet.of(NON_SERVER)) + } +} + +enum class CredentialType { + SERVER, NON_SERVER +} From cafe5d4139d071fc7fbed508ecbb3d97dec79ef6 Mon Sep 17 00:00:00 2001 From: Paul Kraft Date: Mon, 21 Oct 2024 16:28:46 -0700 Subject: [PATCH 17/18] fix tests --- .../modules/storage/secure/CredentialStorageTests.kt | 9 ++++----- .../spezi/modules/storage/secure/CredentialTypes.kt | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt index c7ae8cb03..c62a24c52 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt @@ -8,7 +8,6 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import java.util.EnumSet import javax.inject.Inject @HiltAndroidTest @@ -37,7 +36,7 @@ class CredentialStorageTests { @After fun tearDown() { - credentialStorage.deleteAll(CredentialType.All) + credentialStorage.deleteAll(CredentialTypes.All) } @Test @@ -149,7 +148,7 @@ class CredentialStorageTests { listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } // when - credentialStorage.deleteAll(CredentialType.Server) + credentialStorage.deleteAll(CredentialTypes.Server) val storedServerCredential = credentialStorage.retrieve( username = serverCredential.username, ) @@ -168,7 +167,7 @@ class CredentialStorageTests { listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } // when - credentialStorage.deleteAll(CredentialType.NonServer) + credentialStorage.deleteAll(CredentialTypes.NonServer) val storedServerCredential = credentialStorage.retrieve( username = serverCredential.username, server = serverCredential.server, @@ -188,7 +187,7 @@ class CredentialStorageTests { listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } // when - credentialStorage.deleteAll(CredentialType.All) + credentialStorage.deleteAll(CredentialTypes.All) val storedServerCredential = credentialStorage.retrieve( username = serverCredential.username, ) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt index 253770c2a..216b1044a 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt @@ -5,7 +5,7 @@ import edu.stanford.spezi.modules.storage.secure.CredentialType.SERVER import java.util.EnumSet data class CredentialTypes( - internal val set: EnumSet + internal val set: EnumSet, ) { companion object { val All = CredentialTypes(EnumSet.allOf(CredentialType::class.java)) From 9da53d7b9a0df0041f22f7aea0ac1b59c7cadddb Mon Sep 17 00:00:00 2001 From: Eldi Cano Date: Sun, 27 Oct 2024 20:02:38 +0100 Subject: [PATCH 18/18] minor adjustments --- .../{secure => credential}/CredentialStorageTests.kt | 4 ++-- .../storage/{secure => local}/KeyStorageTests.kt | 2 +- .../spezi/modules/storage/local/LocalStorageTests.kt | 1 - .../storage/{secure => credential}/Credential.kt | 2 +- .../{secure => credential}/CredentialStorage.kt | 11 ++++++----- .../storage/{secure => credential}/CredentialTypes.kt | 6 +++--- .../spezi/modules/storage/di/StorageModule.kt | 8 ++++---- .../modules/storage/{secure => local}/KeyStorage.kt | 2 +- .../spezi/modules/storage/local/LocalStorage.kt | 1 - 9 files changed, 18 insertions(+), 19 deletions(-) rename modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/{secure => credential}/CredentialStorageTests.kt (98%) rename modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/{secure => local}/KeyStorageTests.kt (97%) rename modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/{secure => credential}/Credential.kt (75%) rename modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/{secure => credential}/CredentialStorage.kt (90%) rename modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/{secure => credential}/CredentialTypes.kt (65%) rename modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/{secure => local}/KeyStorage.kt (98%) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorageTests.kt similarity index 98% rename from modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt rename to modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorageTests.kt index c62a24c52..9a1053803 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorageTests.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.modules.storage.secure +package edu.stanford.spezi.modules.storage.credential import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule @@ -45,7 +45,7 @@ class CredentialStorageTests { credentialStorage.store(serverCredential) // when - val serverCredentials = credentialStorage.retrieveAll(server = serverCredential.server) + val serverCredentials = credentialStorage.retrieveAll(server = serverCredential.server!!) val userServerCredential = credentialStorage.retrieve( username = serverCredential.username, server = serverCredential.server, diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorageTests.kt similarity index 97% rename from modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt rename to modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorageTests.kt index 75d6ac470..a48b9d6cd 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorageTests.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.modules.storage.secure +package edu.stanford.spezi.modules.storage.local import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidRule diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt index 82015c99c..cb5fb6f0f 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt @@ -5,7 +5,6 @@ import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import edu.stanford.spezi.core.testing.runTestUnconfined import edu.stanford.spezi.core.utils.UUID -import edu.stanford.spezi.modules.storage.secure.KeyStorage import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/Credential.kt similarity index 75% rename from modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt rename to modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/Credential.kt index 8ff716deb..4093d196c 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/Credential.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/Credential.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.modules.storage.secure +package edu.stanford.spezi.modules.storage.credential import kotlinx.serialization.Serializable diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorage.kt similarity index 90% rename from modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt rename to modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorage.kt index 7c81e7345..95b12a564 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorage.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.modules.storage.secure +package edu.stanford.spezi.modules.storage.credential import edu.stanford.spezi.modules.storage.di.Storage import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory @@ -17,7 +17,7 @@ interface CredentialStorage { ) fun retrieve(username: String, server: String? = null): Credential? - fun retrieveAll(server: String? = null): List + fun retrieveAll(server: String): List fun delete(username: String, server: String? = null) fun deleteAll(types: CredentialTypes) @@ -46,9 +46,10 @@ internal class CredentialStorageImpl @Inject constructor( return storage.getSerializable(storageKey(server, username)) } - override fun retrieveAll(server: String?): List { + override fun retrieveAll(server: String): List { + val serverKey = storageKey(server, "") return storage.allKeys().mapNotNull { key -> - if (key.startsWith(storageKey(server, ""))) { + if (key.startsWith(serverKey)) { storage.getSerializable(key) } else { null @@ -90,7 +91,7 @@ internal class CredentialStorageImpl @Inject constructor( "${server ?: ""}$SERVER_USERNAME_SEPARATOR$username" private companion object { - const val SECURE_STORAGE_FILE_NAME = "${Storage.STORAGE_FILE_PREFIX}SecureStorage" + const val SECURE_STORAGE_FILE_NAME = "${Storage.STORAGE_FILE_PREFIX}CredentialStorage" const val SERVER_USERNAME_SEPARATOR = "__@__" } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialTypes.kt similarity index 65% rename from modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt rename to modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialTypes.kt index 216b1044a..050a4c966 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/CredentialTypes.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialTypes.kt @@ -1,7 +1,7 @@ -package edu.stanford.spezi.modules.storage.secure +package edu.stanford.spezi.modules.storage.credential -import edu.stanford.spezi.modules.storage.secure.CredentialType.NON_SERVER -import edu.stanford.spezi.modules.storage.secure.CredentialType.SERVER +import edu.stanford.spezi.modules.storage.credential.CredentialType.NON_SERVER +import edu.stanford.spezi.modules.storage.credential.CredentialType.SERVER import java.util.EnumSet data class CredentialTypes( diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt index dec561c08..785a5e2b7 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt @@ -5,16 +5,16 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import edu.stanford.spezi.modules.storage.credential.CredentialStorage +import edu.stanford.spezi.modules.storage.credential.CredentialStorageImpl import edu.stanford.spezi.modules.storage.key.KeyValueStorage import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactoryImpl import edu.stanford.spezi.modules.storage.key.KeyValueStorageType +import edu.stanford.spezi.modules.storage.local.KeyStorage +import edu.stanford.spezi.modules.storage.local.KeyStorageImpl import edu.stanford.spezi.modules.storage.local.LocalStorage import edu.stanford.spezi.modules.storage.local.LocalStorageImpl -import edu.stanford.spezi.modules.storage.secure.CredentialStorage -import edu.stanford.spezi.modules.storage.secure.CredentialStorageImpl -import edu.stanford.spezi.modules.storage.secure.KeyStorage -import edu.stanford.spezi.modules.storage.secure.KeyStorageImpl import javax.inject.Singleton @Module diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorage.kt similarity index 98% rename from modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt rename to modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorage.kt index 6c5274fc3..488afb9fd 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/secure/KeyStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorage.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.modules.storage.secure +package edu.stanford.spezi.modules.storage.local import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt index bf88e1bb5..d35428128 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -8,7 +8,6 @@ import edu.stanford.spezi.modules.storage.di.Storage import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.Encrypted import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.EncryptedUsingKeyStore import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.Unencrypted -import edu.stanford.spezi.modules.storage.secure.KeyStorage import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import kotlinx.serialization.DeserializationStrategy