diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 0c1e6af16..ab20337e6 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -237,6 +237,7 @@ dependencies { // Encrypted Key Storage implementation libs.androidx.security.crypto.ktx + implementation libs.androidx.datastore.preferences // view videos implementation libs.androidx.media3.exoplayer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 000000000..d84767041 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.quartz.utils.LargeCache +import java.io.File + +class AccountPreferenceStores( + val rootFilesDir: () -> File, +) { + companion object { + val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList") + val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList") + val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList") + val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList") + + val localRelayServers = stringPreferencesKey("localRelayServers") + val defaultFileServer = stringPreferencesKey("defaultFileServer") + + val latestUserMetadata = stringPreferencesKey("latestUserMetadata") + val latestContactList = stringPreferencesKey("latestContactList") + val latestDMRelayList = stringPreferencesKey("latestDMRelayList") + val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList") + val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList") + val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList") + val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList") + val latestMuteList = stringPreferencesKey("latestMuteList") + val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList") + val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData") + val latestChannelList = stringPreferencesKey("latestChannelList") + val latestCommunityList = stringPreferencesKey("latestCommunityList") + val latestHashtagList = stringPreferencesKey("latestHashtagList") + val latestGeohashList = stringPreferencesKey("latestGeohashList") + val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList") + + val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog") + val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog") + val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog") + + val torSettings = stringPreferencesKey("tor_settings") + + val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion") + } + + private val storeCache = LargeCache>() + + fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences") + + private fun getDataStore(npub: String): DataStore = + storeCache.getOrCreate(npub) { + PreferenceDataStoreFactory.create( + produceFile = { file(npub) }, + ) + } + + fun removeAccount(npub: String) { + file(npub).delete() + storeCache.remove(npub) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt new file mode 100644 index 000000000..07f561976 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.utils.LargeCache +import java.io.File + +class AccountSecretsEncryptedStores( + val rootFilesDir: () -> File, +) { + companion object Companion { + val encryption = KeyStoreEncryption() + val key = stringPreferencesKey("privKey") + val nwc = stringPreferencesKey("nwc") + } + + private val storeCache = LargeCache() + + fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") + + private fun getDataStore(npub: String): EncryptedDataStore = + storeCache.getOrCreate(npub) { + EncryptedDataStore( + PreferenceDataStoreFactory.create( + produceFile = { file(npub) }, + ), + encryption, + ) + } + + suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key) + + suspend fun savePrivateKey( + npub: String, + value: HexKey, + ) { + getDataStore(npub).save(key, value) + } + + suspend fun nwc(npub: String): UpdatablePropertyFlow = + getDataStore(npub).getProperty( + key = nwc, + parser = Nip47WalletConnect.Nip47URI::parser, + serializer = Nip47WalletConnect.Nip47URI::serializer, + ) + + fun removeAccount(npub: String) { + file(npub).delete() + storeCache.remove(npub) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt new file mode 100644 index 000000000..1e5bc5fbb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URI.Companion.parser +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +suspend fun DataStore.getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, +): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + val value = prefs[key] + if (value != null) { + parser(value) + } else { + null + } + }, + update = { newValue -> + if (newValue != null) { + val serialized = serializer(newValue) + if (serialized.isNotBlank()) { + edit { prefs -> + prefs[key] = serialized + } + } else { + edit { prefs -> + prefs.remove(key) + } + } + } else { + edit { prefs -> + prefs.remove(key) + } + } + }, + scope = scope, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt new file mode 100644 index 000000000..b14d65f28 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import java.io.IOException +import java.util.Base64 + +class EncryptedDataStore( + private val store: DataStore, + private val encryption: KeyStoreEncryption = KeyStoreEncryption(), +) { + private fun decode(str: String): ByteArray = Base64.getDecoder().decode(str) + + private fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray())) + + private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString() + + suspend fun remove(key: Preferences.Key) { + store.edit { prefs -> + prefs.remove(key) + } + } + + suspend fun save( + key: Preferences.Key, + value: String, + ) { + store.edit { prefs -> + prefs[key] = encrypt(value) + } + } + + suspend fun get(key: Preferences.Key): String? = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.firstOrNull() + ?.get(key) + ?.let { decrypt(it) } + + suspend fun getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, + ): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + val value = prefs[key] + if (value != null) { + val decrypted = decrypt(value) + if (decrypted.isNotBlank()) { + parser(decrypted) + } else { + null + } + } else { + null + } + }, + update = { newValue -> + if (newValue != null) { + val serialized = serializer(newValue) + if (serialized.isNotBlank()) { + save(key, serialized) + } else { + remove(key) + } + } else { + remove(key) + } + }, + scope = scope, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt new file mode 100644 index 000000000..eca8de687 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec + +class KeyStoreEncryption { + companion object { + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + private const val KEY_ALIAS = "AMETHYST_AES_KEY" + } + + private val cipher = Cipher.getInstance(TRANSFORMATION) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + private fun getKey(): SecretKey { + val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + private fun createKeyStrongBoxIfAvailable(): SecretKey? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + val keyParams = + KeyGenParameterSpec + .Builder(KEY_ALIAS, PURPOSE) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .setIsStrongBoxBacked(true) + .build() + + val generator = KeyGenerator.getInstance(ALGORITHM) + generator.init(keyParams) + generator.generateKey() + } catch (_: StrongBoxUnavailableException) { + null + } + } else { + null + } + + private fun createKeyRegular(): SecretKey { + val keyParams = + KeyGenParameterSpec + .Builder(KEY_ALIAS, PURPOSE) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .build() + + val generator = KeyGenerator.getInstance(ALGORITHM) + generator.init(keyParams) + return generator.generateKey() + } + + private fun createKey(): SecretKey = createKeyStrongBoxIfAvailable() ?: createKeyRegular() + + fun encrypt(bytes: ByteArray): ByteArray { + // Initializes the cipher in encrypt mode and encrypts data + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(bytes) + return iv + encrypted + } + + fun decrypt(bytes: ByteArray): ByteArray? { + // Extracts IV and decrypts the data + val iv = bytes.copyOfRange(0, cipher.blockSize) + val data = bytes.copyOfRange(cipher.blockSize, bytes.size) + cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + return cipher.doFinal(data) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt new file mode 100644 index 000000000..af96b24f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UpdatablePropertyFlow.kt @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn + +class UpdatablePropertyFlow( + flow: Flow, + val update: suspend (T?) -> Unit, + val scope: CoroutineScope, +) { + val stateFlow = + flow + .flowOn(Dispatchers.Default) + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = null, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 604fa8239..b8d3de626 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ biometricKtx = "1.2.0-alpha05" coil = "3.3.0" composeBom = "2025.07.00" coreKtx = "1.16.0" +datastore = "1.1.7" espressoCore = "3.6.1" firebaseBom = "34.0.0" fragmentKtx = "1.8.8" @@ -69,6 +70,7 @@ androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = " androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }