diff --git a/client/android/app/src/main/java/org/tinymux/titan/data/World.kt b/client/android/app/src/main/java/org/tinymux/titan/data/World.kt index f798f7d02..3e9334dc0 100644 --- a/client/android/app/src/main/java/org/tinymux/titan/data/World.kt +++ b/client/android/app/src/main/java/org/tinymux/titan/data/World.kt @@ -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, secureStorage: Boolean): List = + 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) { + 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() } diff --git a/client/android/app/src/main/java/org/tinymux/titan/ui/TitanApp.kt b/client/android/app/src/main/java/org/tinymux/titan/ui/TitanApp.kt index d530b0e31..782147fa6 100644 --- a/client/android/app/src/main/java/org/tinymux/titan/ui/TitanApp.kt +++ b/client/android/app/src/main/java/org/tinymux/titan/ui/TitanApp.kt @@ -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.", diff --git a/client/android/app/src/test/java/org/tinymux/titan/data/WorldRepositorySecurityTest.kt b/client/android/app/src/test/java/org/tinymux/titan/data/WorldRepositorySecurityTest.kt new file mode 100644 index 000000000..7f96d89fd --- /dev/null +++ b/client/android/app/src/test/java/org/tinymux/titan/data/WorldRepositorySecurityTest.kt @@ -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()) + } +}