From 78bc7b3c48eae1b68d3f35c81ea2b959d8136477 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 22 Aug 2026 01:07:10 +0000 Subject: 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. --- app/build.gradle.kts | 1 + .../java/org/terst/feedreader/data/Settings.kt | 55 ++++++++++++++++++++-- 2 files changed, 51 insertions(+), 5 deletions(-) (limited to 'app') diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63ed33a..32313ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") 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 = context.settingsDataStore.data.map { it[SettingsKeys.STASH_BASE_URL] ?: "" } - val stashApiKey: Flow = - 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() } } -- cgit v1.2.3