fix(android): never persist Hydra secrets without EncryptedSharedPreferences (#1892) (#1902)

fix(android): never persist Hydra secrets without EncryptedSharedPreferences (#1892)
This commit is contained in:
Stephen Dennis 2026-07-31 09:37:46 -06:00 committed by GitHub
commit bb28a852c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 145 additions and 24 deletions

View file

@ -62,32 +62,86 @@ data class World(
}
}
class WorldRepository(context: Context) {
private val prefs: SharedPreferences = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"titan_worlds_encrypted",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (_: Exception) {
// Fallback to unencrypted if Keystore unavailable (e.g. emulator)
context.getSharedPreferences("titan_worlds", Context.MODE_PRIVATE)
}
/**
* #1892: never write hydraPass / hydraSession to an unencrypted preference file.
* When EncryptedSharedPreferences cannot be created, world metadata may still
* use the plain store, but secrets are stripped on load and save.
*/
fun World.withoutSecrets(): World = copy(hydraPass = "", hydraSession = "")
/** Pure helper for tests — what [WorldRepository.save] actually persists. */
fun worldsForPersistence(worlds: List<World>, secureStorage: Boolean): List<World> =
if (secureStorage) worlds else worlds.map { it.withoutSecrets() }
class WorldRepository(context: Context) {
/**
* True when the backing store is EncryptedSharedPreferences.
* UI should warn when false so the user knows Hydra secrets cannot be kept.
*/
val isSecureStorageAvailable: Boolean
private val prefs: SharedPreferences
private val key = "worlds_json"
// Migrate from old unencrypted prefs on first run
init {
val oldPrefs = context.getSharedPreferences("titan_worlds", Context.MODE_PRIVATE)
val oldData = oldPrefs.getString(key, null)
if (oldData != null && prefs.getString(key, null) == null) {
prefs.edit().putString(key, oldData).apply()
oldPrefs.edit().remove(key).apply()
var secure = false
var store: SharedPreferences
try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
store = EncryptedSharedPreferences.create(
context,
"titan_worlds_encrypted",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
secure = true
} catch (e: Exception) {
// #1892: do not silently treat this as a full equivalent of encrypted
// storage. Plain prefs may hold non-secret world fields only.
android.util.Log.w(
"WorldRepository",
"EncryptedSharedPreferences unavailable; Hydra secrets will not be persisted",
e,
)
store = context.getSharedPreferences("titan_worlds", Context.MODE_PRIVATE)
secure = false
}
isSecureStorageAvailable = secure
prefs = store
if (secure) {
// Migrate from old unencrypted prefs into the encrypted store once.
val oldPrefs = context.getSharedPreferences("titan_worlds", Context.MODE_PRIVATE)
val oldData = oldPrefs.getString(key, null)
if (oldData != null && prefs.getString(key, null) == null) {
prefs.edit().putString(key, oldData).apply()
oldPrefs.edit().remove(key).apply()
}
} else {
// Scrub any secrets that may already sit in the plain file (prior
// silent-fallback builds, or a keystore failure after secrets were
// written when encryption briefly worked).
scrubPlaintextSecretsOnDisk()
}
}
private fun scrubPlaintextSecretsOnDisk() {
val raw = prefs.getString(key, null) ?: return
try {
val arr = JSONArray(raw)
val worlds = (0 until arr.length()).map { World.fromJson(arr.getJSONObject(it)) }
val cleaned = worldsForPersistence(worlds, secureStorage = false)
val hadSecrets = worlds.any { it.hydraPass.isNotEmpty() || it.hydraSession.isNotEmpty() }
if (hadSecrets) {
val out = JSONArray()
cleaned.forEach { out.put(it.toJson()) }
prefs.edit().putString(key, out.toString()).apply()
}
} catch (_: Exception) {
// Leave raw alone if unreadable; load() will return empty.
}
}
@ -95,15 +149,18 @@ class WorldRepository(context: Context) {
val raw = prefs.getString(key, null) ?: return emptyList()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { World.fromJson(arr.getJSONObject(it)) }
val worlds = (0 until arr.length()).map { World.fromJson(arr.getJSONObject(it)) }
// Never surface secrets from a non-secure store into the app.
worldsForPersistence(worlds, isSecureStorageAvailable)
} catch (_: Exception) {
emptyList()
}
}
fun save(worlds: List<World>) {
val toWrite = worldsForPersistence(worlds, isSecureStorageAvailable)
val arr = JSONArray()
worlds.forEach { arr.put(it.toJson()) }
toWrite.forEach { arr.put(it.toJson()) }
prefs.edit().putString(key, arr.toString()).apply()
}

View file

@ -1394,6 +1394,15 @@ fun WorldManagerDialog(
title = { Text("Worlds") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
// #1892: Keystore failure used to write hydraPass into plain prefs.
if (!worldRepo.isSecureStorageAvailable) {
Text(
"Secure storage unavailable. Hydra passwords and session " +
"tokens will not be saved on this device.",
color = Color(0xFFFFAA00),
modifier = Modifier.padding(bottom = 12.dp)
)
}
if (worlds.isEmpty()) {
Text(
"No saved worlds yet.",

View file

@ -0,0 +1,55 @@
package org.tinymux.titan.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* #1892: secrets must not be scheduled for plaintext persistence when
* EncryptedSharedPreferences is unavailable.
*/
class WorldRepositorySecurityTest {
private fun sampleWorld(
pass: String = "s3cret",
session: String = "sess-token",
) = World(
name = "Test",
host = "example.com",
useHydra = true,
hydraUser = "alice",
hydraPass = pass,
hydraGame = "mux",
hydraSession = session,
)
@Test
fun secureStorageKeepsSecrets() {
val worlds = listOf(sampleWorld())
val out = worldsForPersistence(worlds, secureStorage = true)
assertEquals(1, out.size)
assertEquals("s3cret", out[0].hydraPass)
assertEquals("sess-token", out[0].hydraSession)
}
@Test
fun insecureStorageStripsSecrets() {
val worlds = listOf(sampleWorld())
val out = worldsForPersistence(worlds, secureStorage = false)
assertEquals(1, out.size)
assertEquals("", out[0].hydraPass)
assertEquals("", out[0].hydraSession)
// Non-secret fields retained.
assertEquals("alice", out[0].hydraUser)
assertEquals("mux", out[0].hydraGame)
assertTrue(out[0].useHydra)
}
@Test
fun withoutSecretsIsIdempotent() {
val w = sampleWorld().withoutSecrets()
assertEquals("", w.hydraPass)
assertEquals("", w.hydraSession)
assertEquals(w, w.withoutSecrets())
}
}