From 119ae757b9b8989989cfd6ea45a6624d9fff7a92 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 19:00:15 +0200 Subject: [PATCH] Migrate to SecureStorage --- app/build.gradle | 2 + .../android/account/AccountActivity.kt | 7 +- .../android/account/AccountViewModel.kt | 15 +-- .../project/create/NewProjectScreen.kt | 8 +- .../net/helcel/cowspent/model/DBProject.kt | 2 +- .../persistence/CowspentSQLiteOpenHelper.kt | 14 +-- .../net/helcel/cowspent/util/SecureStorage.kt | 97 +++++++++++++------ .../util/VersatileProjectSyncClient.kt | 17 +--- 8 files changed, 89 insertions(+), 73 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 13796be..e27c227 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -116,4 +116,6 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.13.0' implementation 'androidx.security:security-crypto:1.1.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0' + implementation "androidx.datastore:datastore-preferences:1.2.1" + implementation "com.google.crypto.tink:tink-android:1.22.0" } diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt index 3935a17..e02bcbc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt @@ -69,7 +69,6 @@ class AccountActivity : AppCompatActivity() { } private lateinit var preferences: SharedPreferences - private var oldPassword = "" private var useWebLogin = true private var showLoginDialog by mutableStateOf(false) @@ -222,11 +221,7 @@ class AccountActivity : AppCompatActivity() { private fun legacyLogin() { val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim()) val username = viewModel.username - var password = viewModel.password - - if (password.isEmpty()) { - password = oldPassword - } + val password = viewModel.password performLogin(url, username, password) } diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt index 3e1e3b3..c08b63f 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt @@ -61,14 +61,15 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } else { preferences.getString(AccountActivity.SETTINGS_USERNAME, "") } - val password = if (useSso) { - "" - } else { - SecureStorage.getPassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) - } if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) { viewModelScope.launch { + val password = if (useSso) { + "" + } else { + SecureStorage.getPassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) + } + isValidatingLogin = true isLoggedIn = withContext(Dispatchers.IO) { if (useSso) { @@ -88,7 +89,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } fun logout() { - SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) + viewModelScope.launch { + SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) + } preferences.edit { remove(AccountActivity.SETTINGS_USE_SSO) remove(AccountActivity.SETTINGS_SSO_URL) diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt index ab0efce..f29dff2 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt @@ -121,13 +121,7 @@ fun NewProjectScreen( Spacer(modifier = Modifier.height(8.dp)) if (viewModel.whatTodoIsCreate) { - if (viewModel.projectType == ProjectType.COSPEND) { - Button( - onClick = onChooseFromNextcloud - ) { - Text(stringResource(R.string.new_project_from_nextcloud_tooltip)) - } - } else if (viewModel.projectType == ProjectType.LOCAL) { + if (viewModel.projectType == ProjectType.LOCAL) { Button( onClick = onImportFile ) { diff --git a/app/src/main/java/net/helcel/cowspent/model/DBProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt index 55b7682..19d513c 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBProject.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt @@ -5,7 +5,7 @@ import java.io.Serializable class DBProject( var id: Long, var remoteId: String, - var password: String?, + var password: String, var name: String, var serverUrl: String?, var email: String?, diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt index fc90853..90bdca0 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -321,7 +321,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : values.put(key_name, accountProject.name) values.put(key_archived, accountProject.archivedTs ?: 0L) val id = db.insert(table_account_projects, null, values) - SecureStorage.savePassword(context, "AccountProjectPassword_$id", accountProject.password) + SecureStorage.savePasswordSync(context, "AccountProjectPassword_$id", accountProject.password) return id } @@ -348,7 +348,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject { val id = cursor.getLong(cursor.getColumnIndex(key_id)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) - val password = SecureStorage.getPassword(context, "AccountProjectPassword_$id") + val password = SecureStorage.getPasswordSync(context, "AccountProjectPassword_$id") return DBAccountProject( id, cursor.getString(cursor.getColumnIndex(key_remoteId)), @@ -619,7 +619,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : values.put(key_type, project.type.id) values.put(key_archived, project.archivedTs ?: 0L) val id = db.insert(table_projects, null, values) - SecureStorage.savePassword(context, "ProjectPassword_$id", project.password) + SecureStorage.savePasswordSync(context, "ProjectPassword_$id", project.password) return id } @@ -651,7 +651,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : private fun getProjectFromCursor(cursor: Cursor): DBProject { val id = cursor.getLong(cursor.getColumnIndex(key_id)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) - val password = SecureStorage.getPassword(context, "ProjectPassword_$id") + val password = SecureStorage.getPasswordSync(context, "ProjectPassword_$id") ?: "" return DBProject( id, cursor.getString(cursor.getColumnIndex(key_remoteId)), @@ -678,7 +678,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) - SecureStorage.removePassword(context, "ProjectPassword_$id") + SecureStorage.removePasswordSync(context, "ProjectPassword_$id") } fun updateProject( @@ -693,7 +693,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) SecureStorage.savePassword(context, "ProjectPassword_$projId", newPassword) + if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword) if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) @@ -733,7 +733,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) SecureStorage.savePassword(context, "ProjectPassword_$projId", newPassword) + if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword) if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) diff --git a/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt index dea4986..233e26d 100644 --- a/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt +++ b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt @@ -1,47 +1,84 @@ package net.helcel.cowspent.util import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import androidx.core.content.edit +import android.util.Base64 +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +private val Context.dataStore: DataStore by preferencesDataStore(name = "secure_prefs") object SecureStorage { - private const val SECURE_PREFS_NAME = "secure_prefs" - private var encryptedPrefs: SharedPreferences? = null + private var aead: Aead? = null @Synchronized - fun getEncryptedPrefs(context: Context): SharedPreferences { - encryptedPrefs?.let { return it } - - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + private fun getAead(context: Context): Aead { + aead?.let { return it } + AeadConfig.register() + val masterKeyUri = "android-keystore://secure_storage_master_key" + val keysetManager = AndroidKeysetManager.Builder() + .withSharedPref(context, "tink_keyset", "secure_storage_keyset_prefs") + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri(masterKeyUri) .build() - - val prefs = EncryptedSharedPreferences.create( - context, - SECURE_PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - encryptedPrefs = prefs - return prefs + val newAead = keysetManager.keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + aead = newAead + return newAead } - fun savePassword(context: Context, key: String, password: String?) { - if (password == null) { - removePassword(context, key) - } else { - getEncryptedPrefs(context).edit { putString(key, password) } + private fun encrypt(context: Context, value: String): String { + val encrypted = getAead(context).encrypt(value.toByteArray(), null) + return Base64.encodeToString(encrypted, Base64.NO_WRAP) + } + + private fun decrypt(context: Context, encryptedValue: String): String? { + return try { + val decoded = Base64.decode(encryptedValue, Base64.NO_WRAP) + val decrypted = getAead(context).decrypt(decoded, null) + String(decrypted) + } catch (_: Exception) { + null } } - fun getPassword(context: Context, key: String): String? { - return getEncryptedPrefs(context).getString(key, null) + suspend fun savePassword(context: Context, key: String, password: String?) { + if (password == null) { + removePassword(context, key) + } else { + val encrypted = encrypt(context, password) + context.dataStore.edit { it[stringPreferencesKey(key)] = encrypted } + } } - fun removePassword(context: Context, key: String) { - getEncryptedPrefs(context).edit { remove(key) } + suspend fun getPassword(context: Context, key: String): String? { + val encrypted = context.dataStore.data.map { it[stringPreferencesKey(key)] }.first() + return encrypted?.let { decrypt(context, it) } + } + + suspend fun removePassword(context: Context, key: String) { + context.dataStore.edit { it.remove(stringPreferencesKey(key)) } + } + + // Synchronous alternatives for legacy code + fun savePasswordSync(context: Context, key: String, password: String?) = runBlocking { + savePassword(context, key, password) + } + + fun getPasswordSync(context: Context, key: String): String? = runBlocking { + getPassword(context, key) + } + + fun removePasswordSync(context: Context, key: String) = runBlocking { + removePassword(context, key) } } diff --git a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt index c77bb5d..0d027bf 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -333,7 +333,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for editRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId @@ -386,7 +385,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -433,7 +431,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId @@ -504,8 +501,7 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - } + return ServerResponse.CreateRemoteProjectResponse( requestServer( target, METHOD_POST, paramKeys, paramValues, @@ -584,7 +580,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" @@ -651,7 +646,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -686,8 +680,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - } return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -718,7 +710,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync else project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for getBills") return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -773,7 +764,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for getMembers") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -827,7 +817,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency" @@ -881,7 +870,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId @@ -928,7 +916,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId @@ -1027,7 +1014,6 @@ class VersatileProjectSyncClient( httpCon.setRequestProperty("Accept", "application/json") } httpCon.connectTimeout = 10 * 1000 // 10 seconds - Log.d(javaClass.simpleName, "$method ${SupportUtil.maskUrl(target)}") if (paramKeys != null && paramValues != null) { var dataString = "" for (i in paramKeys.indices) { @@ -1040,7 +1026,6 @@ class VersatileProjectSyncClient( dataString += URLEncoder.encode(value, "UTF-8") } val data = dataString.toByteArray() - Log.d(javaClass.simpleName, "Params: ${SupportUtil.maskParams(dataString)}") httpCon.setFixedLengthStreamingMode(data.size) httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") httpCon.setRequestProperty("Content-Length", data.size.toString())