summaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-22 01:07:10 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-22 01:07:10 +0000
commit78bc7b3c48eae1b68d3f35c81ea2b959d8136477 (patch)
tree0f516424bfea2b8af815a5c218837f5b1804d8c0 /app/src/main/java
parent429594e6bc1069f7e7141060df3a0fd8bf63749b (diff)
Encrypt the Stash API key at rest
The key is a bearer credential for the user's own Stash server, sent as X-API-Key on every request -- previously stored in plain DataStore preferences, recoverable in cleartext from a rooted device, an unlocked-bootloader device, or a local backup/extraction tool. Now stored via EncryptedSharedPreferences (Android Keystore-backed AES-256). An existing key is migrated from the old plaintext location once, on first read, then the plaintext copy is cleared.
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Settings.kt55
1 files changed, 50 insertions, 5 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt
index ce05974..2e7eaae 100644
--- a/app/src/main/java/org/terst/feedreader/data/Settings.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt
@@ -6,15 +6,25 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
val Context.settingsDataStore by preferencesDataStore(name = "settings")
+private const val SECURE_PREFS_NAME = "secure_settings"
+private const val SECURE_API_KEY = "stash_api_key"
+
private object SettingsKeys {
val STASH_BASE_URL = stringPreferencesKey("stash_base_url")
- val STASH_API_KEY = stringPreferencesKey("stash_api_key")
+ // Old plaintext location for the API key -- read once (see migrateLegacyApiKeyIfNeeded)
+ // to move an existing user's key into encrypted storage, then cleared. Nothing writes
+ // to this key anymore.
+ val LEGACY_STASH_API_KEY = stringPreferencesKey("stash_api_key")
val LAST_FETCH_AT = longPreferencesKey("last_fetch_at")
val LAST_FETCH_NEW_ARTICLES = intPreferencesKey("last_fetch_new_articles")
val LAST_ACKNOWLEDGED_FETCH_AT = longPreferencesKey("last_acknowledged_fetch_at")
@@ -24,16 +34,51 @@ private object SettingsKeys {
class Settings(private val context: Context) {
val stashBaseUrl: Flow<String> =
context.settingsDataStore.data.map { it[SettingsKeys.STASH_BASE_URL] ?: "" }
- val stashApiKey: Flow<String> =
- context.settingsDataStore.data.map { it[SettingsKeys.STASH_API_KEY] ?: "" }
suspend fun stashBaseUrlNow(): String = stashBaseUrl.first()
- suspend fun stashApiKeyNow(): String = stashApiKey.first()
+
+ // The API key is a bearer credential for the user's own Stash server (sent as
+ // X-API-Key on every StashClient request) -- stored via EncryptedSharedPreferences
+ // (Android Keystore-backed AES-256), unlike everything else here which lives in plain
+ // DataStore. Plain DataStore is sandboxed under normal circumstances, but is still
+ // recoverable in cleartext from a rooted device, an unlocked-bootloader device, or a
+ // local backup/extraction tool -- letting anyone with that level of access impersonate
+ // the user against their own server. `by lazy` because creating it touches the
+ // Keystore, not worth doing until something actually needs the key.
+ private val securePrefs by lazy {
+ val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
+ EncryptedSharedPreferences.create(
+ context,
+ SECURE_PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ suspend fun stashApiKeyNow(): String = withContext(Dispatchers.IO) {
+ migrateLegacyApiKeyIfNeeded()
+ securePrefs.getString(SECURE_API_KEY, "") ?: ""
+ }
+
+ // One-time move of a key saved before encrypted storage existed, so an existing user
+ // doesn't have to re-enter it: read the old plaintext DataStore value if present,
+ // write it into the encrypted store, then clear the plaintext copy.
+ private suspend fun migrateLegacyApiKeyIfNeeded() {
+ val legacy = context.settingsDataStore.data.map { it[SettingsKeys.LEGACY_STASH_API_KEY] }.first()
+ if (!legacy.isNullOrBlank()) {
+ securePrefs.edit().putString(SECURE_API_KEY, legacy).apply()
+ context.settingsDataStore.edit { prefs -> prefs.remove(SettingsKeys.LEGACY_STASH_API_KEY) }
+ }
+ }
suspend fun setStash(baseUrl: String, apiKey: String) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.STASH_BASE_URL] = baseUrl.trim().trimEnd('/')
- prefs[SettingsKeys.STASH_API_KEY] = apiKey.trim()
+ prefs.remove(SettingsKeys.LEGACY_STASH_API_KEY)
+ }
+ withContext(Dispatchers.IO) {
+ securePrefs.edit().putString(SECURE_API_KEY, apiKey.trim()).apply()
}
}