chore: refactor the internal storage layer #40

Merged
reya merged 3 commits from refactor-storage into master 2026-07-09 07:53:23 +00:00
7 changed files with 84 additions and 86 deletions

View File

@@ -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()
}
}
}
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"))
}
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
return when {

View File

@@ -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
}
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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) }
}