refactor: move the DataStore preference layer into commons (KMP)

Step 1 of the SharedPreferences -> DataStore migration.

androidx.datastore:datastore-preferences 1.2.1 publishes android, jvm,
ios, linux and macos variants plus common metadata, so the preference
layer does not need a port interface to serve the JVM app — the
implementation itself is portable. Moves the dependency from commons'
androidMain to commonMain and the storage infrastructure with it, using
the okio-based `createWithPath` factory because the `java.io.File`
overloads are absent on Apple targets.

Placement follows what each piece actually needs:

- commonMain: DataStoreExt, AccountPreferenceStores (per-account,
  non-secret). No platform API — the root directory is injected, so
  Android passes filesDir, desktop its data dir, tests a temp folder.
- jvmAndroid: SecretEncryption (expect), EncryptedDataStore,
  AccountSecretsEncryptedStores. Android and desktop are the two targets
  that store secrets today; an Apple actual belongs with the first iOS
  build that needs one rather than stubbed here where nothing exercises
  it.
- androidMain: the existing AndroidKeyStore implementation, moved
  verbatim from amethyst so the Marmot stores' data stays readable.
- jvmMain: a new desktop actual — same AES-256-GCM, key in a 0600 file
  this OS user owns. Weaker custody than a TEE, documented as such.

Fixes a real defect in EncryptedDataStore while moving it. `decrypt`
read `encryption.decrypt(...).contentToString()`, which renders a
ByteArray as "[104, 101, 108]" rather than decoding it, so every value
round-tripped to the debug rendering of its own bytes. Nothing caught it
because the class had no callers: AccountPreferenceStores and
AccountSecretsEncryptedStores were dead code, one reference each (their
own declaration). Verified by reintroducing the bug against the new
tests — saveAndGetRoundTrips reads "second" back as
"[115, 101, 99, 111, 110, 100]".

Private keys deliberately do NOT move into AccountSecretsEncryptedStores.
commons already has SecureKeyStorage, backed by the OS credential
manager and already used by desktopApp; a second, weaker identity-key
store would win by being convenient. That store keeps only non-identity
secrets.

13 new tests cover the encrypt/decrypt round trip, that values are not
stored in plaintext, that GCM never reuses an IV, that the key persists
across instances, that a different key cannot decrypt, 0600 permissions,
and that a malformed key file is refused rather than silently replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
Claude
2026-09-23 15:28:20 +00:00
parent c685244a91
commit 6ff9460243
15 changed files with 599 additions and 183 deletions
@@ -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()
@@ -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.
@@ -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) {
@@ -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.
*
* ```
* <rootDir>/marmot_obligations/<obligationId>.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()
@@ -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<String, DataStore<Preferences>>()
fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences")
private fun getDataStore(npub: String): DataStore<Preferences> =
storeCache.getOrCreate(npub) {
PreferenceDataStoreFactory.create(
produceFile = { file(npub) },
)
}
fun removeAccount(npub: String): Boolean {
val deleted = file(npub).delete()
storeCache.remove(npub)
return deleted
}
}
+8 -1
View File
@@ -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)
}
}
@@ -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
@@ -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 `<root>/datastore/<npub>.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<String, DataStore<Preferences>>()
fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.preferences_pb"
fun getDataStore(npub: String): DataStore<Preferences> =
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
}
}
@@ -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 <T> DataStore<Preferences>.getProperty(
key: Preferences.Key<String>,
parser: (String) -> T,
@@ -42,29 +52,14 @@ fun <T> DataStore<Preferences>.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,
@@ -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
* `<root>/datastore/<npub>.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<String, EncryptedDataStore>()
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<Nip47WalletConnect.Nip47URI> =
fun nwc(npub: String): UpdatablePropertyFlow<Nip47WalletConnect.Nip47URI> =
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
}
}
@@ -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<Preferences>,
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<String>) {
store.edit { prefs ->
prefs.remove(key)
}
store.edit { prefs -> prefs.remove(key) }
}
suspend fun save(
key: Preferences.Key<String>,
value: String,
) {
store.edit { prefs ->
prefs[key] = encrypt(value)
}
store.edit { prefs -> prefs[key] = encrypt(value) }
}
suspend fun get(key: Preferences.Key<String>): 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)
}
@@ -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?
}
@@ -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
}
}
}
@@ -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))
}
}
@@ -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"))
}
}