chore: refactor the internal storage layer (#40)
Reviewed-on: #40
This commit was merged in pull request #40.
This commit is contained in:
@@ -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.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
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.nio.charset.StandardCharsets
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
|||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
data class SecretEntry(
|
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||||
val encrypted: String,
|
|
||||||
val iv: String
|
data class SecretEntry(val encrypted: String, val iv: String)
|
||||||
)
|
|
||||||
|
|
||||||
class SecretCrypto {
|
class SecretCrypto {
|
||||||
private val keyAlias = "coop"
|
private val keyAlias = "coop"
|
||||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
|||||||
private val transformation = "AES/GCM/NoPadding"
|
private val transformation = "AES/GCM/NoPadding"
|
||||||
|
|
||||||
fun encrypt(content: String): SecretEntry {
|
fun encrypt(content: String): SecretEntry {
|
||||||
// Initialize cipher
|
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
|
||||||
// Encrypt content
|
|
||||||
val encrypted = cipher.doFinal(content.toByteArray())
|
val encrypted = cipher.doFinal(content.toByteArray())
|
||||||
val iv = cipher.iv
|
val iv = cipher.iv
|
||||||
|
|
||||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
|||||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||||
|
|
||||||
// Initialize cipher
|
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
val spec = GCMParameterSpec(128, iv)
|
val spec = GCMParameterSpec(128, iv)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||||
|
|
||||||
// Decrypt content
|
|
||||||
val plaintext = cipher.doFinal(encrypted)
|
val plaintext = cipher.doFinal(encrypted)
|
||||||
|
|
||||||
return String(plaintext, StandardCharsets.UTF_8)
|
return String(plaintext, StandardCharsets.UTF_8)
|
||||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
|||||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||||
val existingKey = keyStore.getKey(keyAlias, null)
|
val existingKey = keyStore.getKey(keyAlias, null)
|
||||||
|
|
||||||
// Return existing key if available
|
|
||||||
if (existingKey is SecretKey) return existingKey
|
if (existingKey is SecretKey) return existingKey
|
||||||
|
|
||||||
// Construct a new key generator
|
|
||||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||||
|
|
||||||
// Initialize key generation parameters
|
|
||||||
val spec = KeyGenParameterSpec.Builder(
|
val spec = KeyGenParameterSpec.Builder(
|
||||||
keyAlias,
|
keyAlias,
|
||||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
|||||||
.setKeySize(256)
|
.setKeySize(256)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Generate a new key
|
|
||||||
keyGenerator.init(spec)
|
keyGenerator.init(spec)
|
||||||
|
|
||||||
return keyGenerator.generateKey()
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import androidx.activity.viewModels
|
|||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
|
||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
import su.reya.coop.viewmodel.AuthViewModel
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
@@ -26,15 +25,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val factory by lazy {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
|
private val storage = AppStore(this@MainActivity)
|
||||||
|
private val nostrViewModel = NostrViewModel(NostrManager.instance)
|
||||||
|
private val chatViewModel = ChatViewModel(NostrManager.instance)
|
||||||
private val androidSigner =
|
private val androidSigner =
|
||||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
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 =
|
private val authViewModel =
|
||||||
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
AuthViewModel(NostrManager.instance, storage, androidSigner)
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,8 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
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-coroutines-core:1.11.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
|
|||||||
14
shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt
Normal file
14
shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -12,12 +12,12 @@ import rust.nostr.sdk.Keys
|
|||||||
import rust.nostr.sdk.NostrConnect
|
import rust.nostr.sdk.NostrConnect
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.AppStorage
|
||||||
import su.reya.coop.nostr.ExternalSignerHandler
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
import su.reya.coop.nostr.ExternalSignerProxy
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.nostr.SignerPermissions
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
data class AuthState(
|
data class AuthState(
|
||||||
@@ -27,7 +27,7 @@ data class AuthState(
|
|||||||
|
|
||||||
class AuthViewModel(
|
class AuthViewModel(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val secretStore: SecretStorage,
|
private val storage: AppStorage,
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
private val mediaRepository = MediaRepository()
|
private val mediaRepository = MediaRepository()
|
||||||
@@ -51,7 +51,7 @@ class AuthViewModel(
|
|||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ class AuthViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val secret = withTimeoutOrNull(5.seconds) {
|
val secret = withTimeoutOrNull(5.seconds) {
|
||||||
secretStore.get(KEY_USER_SIGNER)
|
storage.getSecret(KEY_USER_SIGNER)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secret == null) {
|
if (secret == null) {
|
||||||
@@ -94,8 +94,8 @@ class AuthViewModel(
|
|||||||
showError("Logout encountered an error: ${e.message}")
|
showError("Logout encountered an error: ${e.message}")
|
||||||
} finally {
|
} finally {
|
||||||
// Clear credentials from persistent storage
|
// Clear credentials from persistent storage
|
||||||
secretStore.clear(KEY_USER_SIGNER)
|
storage.clear(KEY_USER_SIGNER)
|
||||||
secretStore.clear(KEY_BANNER_DISMISSED)
|
storage.clear(KEY_BANNER_DISMISSED)
|
||||||
// Call cleanup callback (e.g. to reset other ViewModels)
|
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||||
onLogout()
|
onLogout()
|
||||||
// Reset local states
|
// Reset local states
|
||||||
@@ -106,18 +106,18 @@ class AuthViewModel(
|
|||||||
|
|
||||||
fun dismissNotificationBanner() {
|
fun dismissNotificationBanner() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
storage.set(KEY_BANNER_DISMISSED, "true")
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
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 app keys are already stored, use them
|
||||||
if (secret != null) return Keys.parse(secret)
|
if (secret != null) return Keys.parse(secret)
|
||||||
// Generate new app keys and save to the secret storage
|
// Generate new app keys and save to the secret storage
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ class AuthViewModel(
|
|||||||
// Update signer
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
// Persist the secret in the secret storage
|
// Persist the secret in the secret storage
|
||||||
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||||
// Update local states
|
// Update local states
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false) }
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ class AuthViewModel(
|
|||||||
// Update signer
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
// Store the signer in the secret storage
|
// 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
|
// Update local states
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false) }
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ class AuthViewModel(
|
|||||||
// Create identity
|
// Create identity
|
||||||
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
||||||
// Persist the secret in the secret storage
|
// Persist the secret in the secret storage
|
||||||
secretStore.set(KEY_USER_SIGNER, secret)
|
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||||
// Update local states
|
// Update local states
|
||||||
_state.update { it.copy(signerRequired = false) }
|
_state.update { it.copy(signerRequired = false) }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user