mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
+3
-3
@@ -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()
|
||||
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
|
||||
-83
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
+68
@@ -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
-23
@@ -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,
|
||||
+31
-25
@@ -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
-34
@@ -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)
|
||||
}
|
||||
+41
@@ -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?
|
||||
}
|
||||
+146
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -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))
|
||||
}
|
||||
}
|
||||
+118
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user