diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/AppStorage.kt similarity index 52% rename from composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt rename to composeApp/src/androidMain/kotlin/su/reya/coop/AppStorage.kt index dac0de6..ef3805d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretCrypto.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/AppStorage.kt @@ -1,8 +1,13 @@ -package su.reya.coop.coop.storage +package su.reya.coop +import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first import java.nio.charset.StandardCharsets import java.security.KeyStore import javax.crypto.Cipher @@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -data class SecretEntry( - val encrypted: String, - val iv: String -) +private val Context.dataStore by preferencesDataStore("secret_store") + +data class SecretEntry(val encrypted: String, val iv: String) class SecretCrypto { private val keyAlias = "coop" @@ -21,11 +25,9 @@ class SecretCrypto { private val transformation = "AES/GCM/NoPadding" fun encrypt(content: String): SecretEntry { - // Initialize cipher val cipher = Cipher.getInstance(transformation) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) - // Encrypt content val encrypted = cipher.doFinal(content.toByteArray()) val iv = cipher.iv @@ -39,12 +41,10 @@ class SecretCrypto { val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP) val iv = Base64.decode(entry.iv, Base64.NO_WRAP) - // Initialize cipher val cipher = Cipher.getInstance(transformation) val spec = GCMParameterSpec(128, iv) cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) - // Decrypt content val plaintext = cipher.doFinal(encrypted) return String(plaintext, StandardCharsets.UTF_8) @@ -54,13 +54,9 @@ class SecretCrypto { val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) } val existingKey = keyStore.getKey(keyAlias, null) - // Return existing key if available if (existingKey is SecretKey) return existingKey - // Construct a new key generator val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType) - - // Initialize key generation parameters val spec = KeyGenParameterSpec.Builder( keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT @@ -70,9 +66,49 @@ class SecretCrypto { .setKeySize(256) .build() - // Generate a new key keyGenerator.init(spec) - return keyGenerator.generateKey() } -} \ No newline at end of file +} + +class AppStore(private val context: Context) : AppStorage { + private val crypto = SecretCrypto() + + override suspend fun get(key: String): String? { + return context.dataStore.data.first()[stringPreferencesKey(key)] + } + + override suspend fun set(key: String, value: String) { + context.dataStore.edit { it[stringPreferencesKey(key)] = value } + } + + override suspend fun getSecret(key: String): String? { + val prefs = context.dataStore.data.first() + val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null + val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null + + return crypto.decrypt(SecretEntry(encrypted, iv)) + } + + override suspend fun setSecret(key: String, value: String) { + val entry = crypto.encrypt(value) + context.dataStore.edit { prefs -> + prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted + prefs[stringPreferencesKey("${key}_iv")] = entry.iv + } + } + + override suspend fun clear(key: String) { + context.dataStore.edit { prefs -> + prefs.remove(stringPreferencesKey(key)) + prefs.remove(stringPreferencesKey("${key}_encrypted")) + prefs.remove(stringPreferencesKey("${key}_iv")) + } + } + + override suspend fun has(key: String): Boolean { + val prefs = context.dataStore.data.first() + return prefs.contains(stringPreferencesKey(key)) || + prefs.contains(stringPreferencesKey("${key}_encrypted")) + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index ec77b12..d56a596 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -12,7 +12,6 @@ import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import su.reya.coop.coop.storage.SecretStore import su.reya.coop.nostr.NostrManager import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.viewmodel.ChatViewModel @@ -26,15 +25,13 @@ class MainActivity : ComponentActivity() { private val factory by lazy { object : ViewModelProvider.Factory { + private val storage = AppStore(this@MainActivity) + private val nostrViewModel = NostrViewModel(NostrManager.instance) + private val chatViewModel = ChatViewModel(NostrManager.instance) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val secretStore = SecretStore(this@MainActivity) - private val nostrViewModel = - NostrViewModel(NostrManager.instance) - private val chatViewModel = - ChatViewModel(NostrManager.instance) private val authViewModel = - AuthViewModel(NostrManager.instance, secretStore, androidSigner) + AuthViewModel(NostrManager.instance, storage, androidSigner) override fun create(modelClass: Class): T { return when { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt deleted file mode 100644 index 37ffc47..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt +++ /dev/null @@ -1,43 +0,0 @@ -package su.reya.coop.coop.storage - -import android.content.Context -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.flow.first -import su.reya.coop.storage.SecretStorage - -private val Context.dataStore by preferencesDataStore("secret_store") - -class SecretStore(private val context: Context) : SecretStorage { - private val crypto = SecretCrypto() - - override suspend fun set(key: String, value: String) { - val entry = crypto.encrypt(value) - - context.dataStore.edit { prefs -> - prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted - prefs[stringPreferencesKey("${key}_iv")] = entry.iv - } - } - - override suspend fun get(key: String): String? { - val prefs = context.dataStore.data.first() - val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null - val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null - - return crypto.decrypt(SecretEntry(encrypted, iv)) - } - - override suspend fun clear(key: String) { - context.dataStore.edit { prefs -> - prefs.remove(stringPreferencesKey("${key}_encrypted")) - prefs.remove(stringPreferencesKey("${key}_iv")) - } - } - - override suspend fun has(key: String): Boolean { - val prefs = context.dataStore.data.first() - return prefs[stringPreferencesKey("${key}_encrypted")] != null - } -} \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 276ec31..9f1965a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.datastore) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("su.reya:nostr-sdk-kmp:0.3.2") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt b/shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt new file mode 100644 index 0000000..af93987 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt @@ -0,0 +1,14 @@ +package su.reya.coop + +interface AppStorage { + // Plain text storage + suspend fun get(key: String): String? + suspend fun set(key: String, value: String) + + // Encrypted storage + suspend fun getSecret(key: String): String? + suspend fun setSecret(key: String, value: String) + + suspend fun clear(key: String) + suspend fun has(key: String): Boolean +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt b/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt deleted file mode 100644 index 177dc00..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt +++ /dev/null @@ -1,8 +0,0 @@ -package su.reya.coop.storage - -interface SecretStorage { - suspend fun get(key: String): String? - suspend fun set(key: String, value: String) - suspend fun clear(key: String) - suspend fun has(key: String): Boolean -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index e77db2e..0492606 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -12,12 +12,12 @@ import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import su.reya.coop.AppStorage import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.SignerPermissions import su.reya.coop.repository.MediaRepository -import su.reya.coop.storage.SecretStorage import kotlin.time.Duration.Companion.seconds data class AuthState( @@ -27,7 +27,7 @@ data class AuthState( class AuthViewModel( private val nostr: Nostr, - private val secretStore: SecretStorage, + private val storage: AppStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : BaseViewModel() { private val mediaRepository = MediaRepository() @@ -51,7 +51,7 @@ class AuthViewModel( private fun checkNotificationBannerDismissedStatus() { viewModelScope.launch { - val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" + val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" _state.update { it.copy(isNotificationBannerDismissed = dismissed) } } } @@ -60,7 +60,7 @@ class AuthViewModel( viewModelScope.launch { try { val secret = withTimeoutOrNull(5.seconds) { - secretStore.get(KEY_USER_SIGNER) + storage.getSecret(KEY_USER_SIGNER) } if (secret == null) { @@ -94,8 +94,8 @@ class AuthViewModel( showError("Logout encountered an error: ${e.message}") } finally { // Clear credentials from persistent storage - secretStore.clear(KEY_USER_SIGNER) - secretStore.clear(KEY_BANNER_DISMISSED) + storage.clear(KEY_USER_SIGNER) + storage.clear(KEY_BANNER_DISMISSED) // Call cleanup callback (e.g. to reset other ViewModels) onLogout() // Reset local states @@ -106,18 +106,18 @@ class AuthViewModel( fun dismissNotificationBanner() { viewModelScope.launch { - secretStore.set(KEY_BANNER_DISMISSED, "true") + storage.set(KEY_BANNER_DISMISSED, "true") _state.update { it.copy(isNotificationBannerDismissed = true) } } } private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get(KEY_APP_KEYS) + val secret = storage.getSecret(KEY_APP_KEYS) // If app keys are already stored, use them if (secret != null) return Keys.parse(secret) // Generate new app keys and save to the secret storage val keys = Keys.generate() - secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) + storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) return keys } @@ -166,7 +166,7 @@ class AuthViewModel( // Update signer nostr.setSigner(signer) // Persist the secret in the secret storage - secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret) + storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) // Update local states _state.update { it.copy(signerRequired = false) } } @@ -195,7 +195,7 @@ class AuthViewModel( // Update signer nostr.setSigner(signer) // Store the signer in the secret storage - secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") + storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") // Update local states _state.update { it.copy(signerRequired = false) } } @@ -218,7 +218,7 @@ class AuthViewModel( // Create identity nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) // Persist the secret in the secret storage - secretStore.set(KEY_USER_SIGNER, secret) + storage.setSecret(KEY_USER_SIGNER, secret) // Update local states _state.update { it.copy(signerRequired = false) } }