diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt index 7a99f80347..04c0db4493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -40,12 +40,12 @@ import java.io.File * The blob contains private key material — init keys, encryption keys, * signature keys — that the MLS engine needs to process Welcome events * received days or weeks after the corresponding KeyPackage was published. - * It is encrypted at rest with [KeyStoreEncryption] (AES/GCM via Android + * It is encrypted at rest with [SecretEncryption] (AES/GCM via Android * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. */ class AndroidKeyPackageBundleStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : KeyPackageBundleStore { private val mutex = Mutex() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt index e7641cb3bf..e6775aa56a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.model.marmot import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.Log @@ -46,7 +46,7 @@ import java.io.File */ class AndroidMarmotMessageStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotMessageStore { private val logMutex = Mutex() @@ -324,7 +324,7 @@ class AndroidMarmotMessageStore( EncryptedAppendLog( encrypt = { encryption.encrypt(it) }, // EncryptedAppendLog requires null, not a throw, for a segment it - // cannot open — KeyStoreEncryption.decrypt rethrows. Without this + // cannot open — SecretEncryption.decrypt rethrows. Without this // one bad segment would abort the whole read, and a caller that // then sees an empty log can overwrite a history that was merely // unreadable. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt index 504037d630..8d509ed7fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -32,7 +32,7 @@ import java.io.FileOutputStream * Android implementation of [MlsGroupStateStore] using file-based encrypted storage. * * All MLS group state (containing private keys and epoch secrets) is encrypted - * at rest using [KeyStoreEncryption] (AES/GCM backed by Android KeyStore). + * at rest using [SecretEncryption] (AES/GCM backed by Android KeyStore). * * Storage layout: * ``` @@ -43,7 +43,7 @@ import java.io.FileOutputStream */ class AndroidMlsGroupStateStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MlsGroupStateStore { init { Log.d(TAG) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt index 445e91ee91..64b561935e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log @@ -32,7 +32,7 @@ import java.io.File /** * Android implementation of [MarmotPublishObligationStore], encrypted at rest - * with [KeyStoreEncryption] like the group-state and KeyPackage stores. + * with [SecretEncryption] like the group-state and KeyPackage stores. * * ``` * /marmot_obligations/.obligation @@ -51,7 +51,7 @@ import java.io.File */ class AndroidPublishObligationStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotPublishObligationStore { private val mutex = Mutex() 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 deleted file mode 100644 index c7fdad1616..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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.cache.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): Boolean { - val deleted = file(npub).delete() - storeCache.remove(npub) - return deleted - } -} diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index b952fe67af..4d6a78e368 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -90,6 +90,14 @@ kotlin { // OkHttp), so declare the dependency the file actually has. implementation(libs.okio) + // DataStore (KMP, Apache-2.0) — the preference storage layer. + // Publishes android/jvm/ios/linux/macos variants plus common + // metadata, so the stores under model/preferences/ are shared + // rather than duplicated per front end. Uses the okio-based + // `createWithPath` factory in common; the `java.io.File` + // overloads are jvmAndroid-only. + implementation(libs.androidx.datastore.preferences) + // Immutable collections api(libs.kotlinx.collections.immutable) @@ -161,7 +169,6 @@ kotlin { // Secure key storage via Android Keystore implementation(libs.androidx.security.crypto.ktx) - implementation(libs.androidx.datastore.preferences) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt index 68c4efb75e..6e48a17de5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.amethyst.commons.model.preferences import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -33,9 +33,9 @@ import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec -class KeyStoreEncryption { +actual class SecretEncryption { companion object { - private const val TAG = "KeyStoreEncryption" + private const val TAG = "SecretEncryption" private const val ANDROID_KEY_STORE = "AndroidKeyStore" private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM @@ -147,7 +147,7 @@ class KeyStoreEncryption { return createKeyStrongBoxIfAvailable() ?: createKeyRegular() } - fun encrypt(bytes: ByteArray): ByteArray { + actual fun encrypt(bytes: ByteArray): ByteArray { try { // Initializes the cipher in encrypt mode and encrypts data val cipher = ciphers.get() @@ -164,7 +164,7 @@ class KeyStoreEncryption { } } - fun decrypt(bytes: ByteArray): ByteArray? { + actual fun decrypt(bytes: ByteArray): ByteArray? { try { // Extract the 12-byte GCM IV prefix and decrypt the remainder. The // AndroidKeyStore cipher only accepts GCMParameterSpec (not a plain diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 0000000000..ed544ac080 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,68 @@ +/* + * 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.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.cache.LargeCache +import okio.Path + +/** + * The per-account, non-secret preference store: one DataStore file per npub, + * at `/datastore/.preferences_pb`. + * + * The root directory is injected rather than discovered so every front end can + * say where its data lives — `filesDir` on Android, the app data directory on + * desktop, a temp folder in tests — and so this class needs no platform API of + * its own. + * + * Built on [PreferenceDataStoreFactory.createWithPath], the okio-based factory, + * because the `java.io.File` overloads are absent on Apple targets. + */ +class AccountPreferenceStores( + val rootFilesDir: () -> Path, +) { + private val storeCache = LargeCache>() + + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.preferences_pb" + + fun getDataStore(npub: String): DataStore = + storeCache.getOrCreate(npub) { + PreferenceDataStoreFactory.createWithPath(produceFile = { file(npub) }) + } + + /** + * Drops the account's stored preferences. + * + * The cached handle goes first: deleting the file under a live DataStore + * would leave that instance writing the account's settings back out on the + * next edit, re-creating what this call is meant to erase. + */ + fun removeAccount(npub: String): Boolean { + storeCache.remove(npub) + val path = file(npub) + if (!platformFileSystem.exists(path)) return false + platformFileSystem.delete(path) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt index 453dd70212..0358899d51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt @@ -18,18 +18,28 @@ * 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 +package com.vitorpamplona.amethyst.commons.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.commons.model.preferences.UpdatablePropertyFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map -import java.io.IOException +import okio.IOException +/** + * Exposes one DataStore key as an [UpdatablePropertyFlow]. + * + * A missing key, a blank serialization and an explicit null all mean the same + * thing here — the property is absent — so each of them removes the key rather + * than storing an empty string that would later parse into a bogus value. + * + * Uses okio's [IOException] rather than `java.io.IOException`: on JVM targets + * okio aliases it to exactly that type, so the read-error branch keeps catching + * what DataStore throws while the file stays compilable for Apple targets. + */ fun DataStore.getProperty( key: Preferences.Key, parser: (String) -> T, @@ -42,29 +52,14 @@ fun DataStore.getProperty( .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }.map { prefs -> - val value = prefs[key] - if (value != null) { - parser(value) - } else { - null - } + prefs[key]?.let(parser) }, update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - edit { prefs -> - prefs[key] = serialized - } - } else { - edit { prefs -> - prefs.remove(key) - } - } + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + edit { prefs -> prefs[key] = serialized } } else { - edit { prefs -> - prefs.remove(key) - } + edit { prefs -> prefs.remove(key) } } }, scope = scope, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt similarity index 55% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt index b9318a4713..60300c1498 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -18,61 +18,67 @@ * 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 +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.amethyst.commons.util.platformFileSystem import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope -import java.io.File +import okio.Path +/** + * The per-account secret store: one encrypted DataStore per npub, at + * `/datastore/.secrets_pb`, for secrets that are not the identity + * key — wallet connection strings, NIP-46 bunker material. + * + * **Private keys do not belong here.** They live in + * [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage], which backs + * them with the OS credential manager (Keychain, Credential Manager, Secret + * Service) rather than a file this process can read, and which desktop already + * uses. Two stores for one identity key would be one store too many, and the + * weaker one would win by being convenient. + * + * Separate from [AccountPreferenceStores] so ordinary settings stay cheap to + * read: every value here pays an encrypt/decrypt, which on a StrongBox-backed + * device runs at roughly 68 KB/s. + */ class AccountSecretsEncryptedStores( - val rootFilesDir: () -> File, + val rootFilesDir: () -> Path, val scope: CoroutineScope, + private val encryption: SecretEncryption = SecretEncryption(), ) { - companion object Companion { - val encryption = KeyStoreEncryption() - val key = stringPreferencesKey("privKey") + companion object { val nwc = stringPreferencesKey("nwc") } private val storeCache = LargeCache() - fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.secrets_pb" - private fun getDataStore(npub: String): EncryptedDataStore = + fun getDataStore(npub: String): EncryptedDataStore = storeCache.getOrCreate(npub) { EncryptedDataStore( - PreferenceDataStoreFactory.create( - produceFile = { file(npub) }, - ), + PreferenceDataStoreFactory.createWithPath(produceFile = { file(npub) }), encryption, scope = scope, ) } - 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 = + fun nwc(npub: String): UpdatablePropertyFlow = getDataStore(npub).getProperty( key = nwc, parser = Nip47WalletConnect.Nip47URI::parser, serializer = Nip47WalletConnect.Nip47URI::serializer, ) + /** See [AccountPreferenceStores.removeAccount] — the cached handle goes first. */ fun removeAccount(npub: String): Boolean { - val deleted = file(npub).delete() storeCache.remove(npub) - return deleted + val path = file(npub) + if (!platformFileSystem.exists(path)) return false + platformFileSystem.delete(path) + return true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt similarity index 64% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt index 49091d62c7..710102f222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt @@ -18,46 +18,44 @@ * 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 +package com.vitorpamplona.amethyst.commons.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.commons.model.preferences.UpdatablePropertyFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import java.io.IOException +import okio.IOException import kotlin.io.encoding.Base64 +/** + * A DataStore whose values are encrypted with [SecretEncryption] and stored + * Base64-encoded, for anything that must not sit in plaintext on disk. + * + * Keys stay in the clear — only values are encrypted — so the set of keys an + * account has is visible even though their contents are not. + */ class EncryptedDataStore( private val store: DataStore, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), private val scope: CoroutineScope, ) { - private fun decode(str: String): ByteArray = Base64.decode(str) + private fun encrypt(value: String): String = Base64.encode(encryption.encrypt(value.encodeToByteArray())) - private fun encode(bytes: ByteArray): String = Base64.encode(bytes) - - private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray())) - - private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString() + private fun decrypt(value: String): String? = encryption.decrypt(Base64.decode(value))?.decodeToString() suspend fun remove(key: Preferences.Key) { - store.edit { prefs -> - prefs.remove(key) - } + store.edit { prefs -> prefs.remove(key) } } suspend fun save( key: Preferences.Key, value: String, ) { - store.edit { prefs -> - prefs[key] = encrypt(value) - } + store.edit { prefs -> prefs[key] = encrypt(value) } } suspend fun get(key: Preferences.Key): String? = @@ -79,26 +77,12 @@ class EncryptedDataStore( .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 - } + prefs[key]?.let { decrypt(it) }?.takeIf { it.isNotBlank() }?.let(parser) }, update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - save(key, serialized) - } else { - remove(key) - } + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + save(key, serialized) } else { remove(key) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt new file mode 100644 index 0000000000..22ad8f450e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt @@ -0,0 +1,41 @@ +/* + * 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.commons.model.preferences + +/** + * Symmetric encryption for data this app stores at rest — account secrets, the + * Marmot group state, message bodies. + * + * Declared for `jvmAndroid` rather than `commonMain` on purpose: Android backs + * it with the hardware-held AndroidKeyStore and desktop with a key file the OS + * user owns, and those are the two targets that store secrets today. An Apple + * actual belongs with the first iOS build that needs one, written against the + * Keychain — not stubbed here, where nothing would exercise it. + * + * Implementations must be safe to call from several coroutines at once. + */ +expect class SecretEncryption() { + /** Returns the ciphertext with whatever nonce/IV the implementation needs prefixed. */ + fun encrypt(bytes: ByteArray): ByteArray + + /** Inverse of [encrypt]. Throws if the input is not what [encrypt] produced. */ + fun decrypt(bytes: ByteArray): ByteArray? +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt new file mode 100644 index 0000000000..a1c9246919 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt @@ -0,0 +1,146 @@ +/* + * 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.commons.model.preferences + +import com.vitorpamplona.amethyst.commons.util.restrictToOwner +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Desktop [SecretEncryption]: the same AES-256-GCM as Android, but with the key + * held in a file this OS user owns instead of in hardware. + * + * That difference is real and worth stating plainly. On Android the key lives + * in the AndroidKeyStore and never enters app memory, so a copy of the data + * files is useless without the device. Here the key sits next to the data, + * readable by anything running as this user — encryption at rest that survives + * a stolen disk or a careless backup, not a compromised account. The JVM has no + * portable hardware-backed keystore to do better with; a per-OS keyring binding + * (as `cli`'s SecretStore does for credentials) is the upgrade path. + */ +actual class SecretEncryption internal constructor( + private val keyFile: File, +) { + /** Production entry point: the key file this OS user owns. */ + actual constructor() : this(defaultKeyFile()) + + companion object { + private const val TAG = "SecretEncryption" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ALGORITHM = "AES" + private const val KEY_SIZE_BYTES = 32 + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH_BITS = 128 + private const val KEY_FILE_NAME = "secret.key" + + /** Where this OS keeps per-user application data. */ + internal fun defaultKeyFile(): File { + val home = System.getProperty("user.home") ?: "." + val os = System.getProperty("os.name").orEmpty().lowercase() + val dir = + when { + os.contains("mac") || os.contains("darwin") -> + File(home, "Library/Application Support/Amethyst") + os.contains("win") -> + File(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", "Amethyst") + else -> + File(System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "$home/.local/share", "amethyst") + } + return File(dir, KEY_FILE_NAME) + } + } + + // A Cipher holds the state of the operation in progress, so two coroutines + // encrypting through one instance would corrupt each other's output. One + // per thread, matching the Android actual. + private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) } + + @Volatile + private var cachedKey: SecretKey? = null + + private fun getKey(): SecretKey = + cachedKey ?: synchronized(this) { + cachedKey ?: loadOrCreateKey().also { cachedKey = it } + } + + private fun loadOrCreateKey(): SecretKey { + if (keyFile.exists()) { + val bytes = keyFile.readBytes() + if (bytes.size == KEY_SIZE_BYTES) return SecretKeySpec(bytes, ALGORITHM) + // A truncated or padded key file cannot decrypt anything already + // written; replacing it silently would strand that data under a key + // nobody holds. Fail loudly instead. + throw IllegalStateException( + "Key file ${keyFile.absolutePath} is ${bytes.size} bytes, expected $KEY_SIZE_BYTES. " + + "Refusing to overwrite it — move it aside to start fresh.", + ) + } + return createKey() + } + + private fun createKey(): SecretKey { + Log.d(TAG) { "Creating a new AES key at ${keyFile.absolutePath}" } + val bytes = ByteArray(KEY_SIZE_BYTES).also { SecureRandom().nextBytes(it) } + + keyFile.parentFile?.let { parent -> + parent.mkdirs() + parent.restrictToOwner(TAG) + } + // Narrow the file before the key goes in: created at the default umask + // and chmodded afterwards, the key would be world-readable in between. + keyFile.createNewFile() + keyFile.restrictToOwner(TAG) + keyFile.writeBytes(bytes) + + return SecretKeySpec(bytes, ALGORITHM) + } + + actual fun encrypt(bytes: ByteArray): ByteArray { + try { + val cipher = ciphers.get() + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + return cipher.iv + cipher.doFinal(bytes) + } catch (e: Exception) { + cachedKey = null + Log.e(TAG, "encrypt() failed: ${e.message}", e) + throw e + } + } + + actual fun decrypt(bytes: ByteArray): ByteArray? { + try { + val iv = bytes.copyOfRange(0, GCM_IV_LENGTH) + val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size) + val cipher = ciphers.get() + cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + return cipher.doFinal(data) + } catch (e: Exception) { + cachedKey = null + Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e) + throw e + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt new file mode 100644 index 0000000000..68eddfbf94 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -0,0 +1,134 @@ +/* + * 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.commons.model.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Round-trip coverage for the encrypted store. + * + * The store shipped with `decrypt` reading + * `encryption.decrypt(...).contentToString()`, which renders a ByteArray as + * `"[104, 101, 108]"` instead of decoding it, so every value read back was the + * debug rendering of its own bytes. Nothing caught it because the class had no + * callers. [saveAndGetRoundTrips] is the test that fails against that version. + */ +class EncryptedDataStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private val key = stringPreferencesKey("nwc") + + private var seq = 0 + + /** + * A store over its own pair of fresh paths. + * + * The files are named, never created: DataStore writes them itself, and an + * empty file left behind by `newFile()` is not a valid preferences_pb. The + * path is resolved once and captured, because `produceFile` may be invoked + * more than once and must answer the same file every time. + */ + private fun store(scope: CoroutineScope): EncryptedDataStore { + val n = seq++ + val dataFile = File(folder.root, "secrets_$n.preferences_pb") + val keyFile = File(folder.root, "secret_$n.key") + return EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = scope, + ) + } + + @Test + fun saveAndGetRoundTrips() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val nsec = "e5e2b1d3f6a94c8d7b0e1f2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d" + + subject.save(key, nsec) + + assertEquals(nsec, subject.get(key)) + } + + @Test + fun missingKeyReadsBackAsNull() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertNull(store(scope).get(key)) + } + + @Test + fun valuesAreNotStoredInPlaintext() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val file = File(folder.root, "plaintext_check.preferences_pb") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }), + SecretEncryption(File(folder.root, "plaintext_check.key")), + scope = scope, + ) + val secret = "correct-horse-battery-staple" + + subject.save(key, secret) + + val onDisk = file.readBytes().decodeToString() + assertEquals("the secret must not be readable in the store file", false, onDisk.contains(secret)) + } + + @Test + fun overwriteReplacesTheValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.save(key, "first") + subject.save(key, "second") + + assertEquals("second", subject.get(key)) + } + + @Test + fun removeClearsTheValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.save(key, "value") + subject.remove(key) + + assertNull(subject.get(key)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt new file mode 100644 index 0000000000..854eef57ab --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt @@ -0,0 +1,118 @@ +/* + * 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.commons.model.preferences + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +class SecretEncryptionTest { + @get:Rule + val folder = TemporaryFolder() + + private fun subject(name: String = "secret.key") = SecretEncryption(File(folder.root, name)) + + @Test + fun roundTripsBytes() { + val encryption = subject() + val plaintext = "an nsec, a wallet string, a group state".encodeToByteArray() + + assertArrayEquals(plaintext, encryption.decrypt(encryption.encrypt(plaintext))) + } + + @Test + fun roundTripsEmptyInput() { + val encryption = subject() + + assertArrayEquals(ByteArray(0), encryption.decrypt(encryption.encrypt(ByteArray(0)))) + } + + @Test + fun ciphertextDiffersFromPlaintext() { + val plaintext = "correct-horse-battery-staple".encodeToByteArray() + + val ciphertext = subject().encrypt(plaintext) + + assertFalse(ciphertext.decodeToString().contains("correct-horse")) + } + + /** GCM must never reuse an IV under the same key, so the same input encrypts differently each time. */ + @Test + fun encryptingTwiceProducesDifferentCiphertext() { + val encryption = subject() + val plaintext = "same input".encodeToByteArray() + + assertNotEquals( + encryption.encrypt(plaintext).toList(), + encryption.encrypt(plaintext).toList(), + ) + } + + /** A second instance over the same key file must read the first one's output. */ + @Test + fun keyPersistsAcrossInstances() { + val plaintext = "survives a restart".encodeToByteArray() + val ciphertext = subject().encrypt(plaintext) + + assertArrayEquals(plaintext, subject().decrypt(ciphertext)) + } + + /** A different key file must not decrypt — otherwise the key is not doing anything. */ + @Test + fun aDifferentKeyCannotDecrypt() { + val ciphertext = subject("first.key").encrypt("secret".encodeToByteArray()) + + assertThrows(Exception::class.java) { subject("second.key").decrypt(ciphertext) } + } + + @Test + fun keyFileIsOwnerOnly() { + subject().encrypt("x".encodeToByteArray()) + + val perms = Files.getPosixFilePermissions(File(folder.root, "secret.key").toPath()) + assertEquals(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), perms) + } + + /** + * A wrong-sized key file means data already on disk was written under a key + * we no longer have. Overwriting it would strand that data silently. + */ + @Test + fun refusesToOverwriteAMalformedKeyFile() { + val keyFile = File(folder.root, "truncated.key") + keyFile.writeBytes(ByteArray(7)) + + val error = + assertThrows(IllegalStateException::class.java) { + SecretEncryption(keyFile).encrypt("x".encodeToByteArray()) + } + assertTrue(error.message!!.contains("Refusing to overwrite")) + } +}