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