diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4f8f9b..e1b1635 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,7 @@ android { defaultConfig { applicationId = "net.spacealtctrl.discordrp" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 36 versionCode = (findProperty("versionCodeOverride") as String?)?.toInt() @@ -110,4 +111,6 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit) + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") } diff --git a/app/src/androidTest/assets/update-foreign-signer.apk b/app/src/androidTest/assets/update-foreign-signer.apk new file mode 100644 index 0000000..421d0dc Binary files /dev/null and b/app/src/androidTest/assets/update-foreign-signer.apk differ diff --git a/app/src/androidTest/assets/update-multi-signer.apk b/app/src/androidTest/assets/update-multi-signer.apk new file mode 100644 index 0000000..8e550d9 Binary files /dev/null and b/app/src/androidTest/assets/update-multi-signer.apk differ diff --git a/app/src/androidTest/assets/update-unsigned.apk b/app/src/androidTest/assets/update-unsigned.apk new file mode 100644 index 0000000..ea3940e Binary files /dev/null and b/app/src/androidTest/assets/update-unsigned.apk differ diff --git a/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt new file mode 100644 index 0000000..2852831 --- /dev/null +++ b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt @@ -0,0 +1,140 @@ +package net.spacealtctrl.discordrp.settings + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Exercises the storage rules against a dedicated preferences file and key + * alias. The live session in "stash" is never written or re-keyed, so a killed + * test process cannot sign the developer out. + */ +@RunWith(AndroidJUnit4::class) +class TokenStorageTest { + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val prefs + get() = context.getSharedPreferences(TEST_PREFS, Context.MODE_PRIVATE) + + private fun stash() = Stash.isolated(context, TEST_PREFS) + + @Before + fun resetStore() { + context.deleteSharedPreferences(TEST_PREFS) + } + + @After + fun removeStore() { + // Flush any apply() still queued, then drop the file so test tokens do + // not linger between runs. + prefs.edit().clear().commit() + context.deleteSharedPreferences(TEST_PREFS) + } + + @Test + fun legacyTokenIsMigrated() { + prefs.edit().putString("discord.token", "legacy-token").commit() + + val stash = stash() + assertEquals("legacy-token", stash.authToken) + assertNull(prefs.getString("discord.token", null)) + assertNotNull(prefs.getString("discord.token.enc", null)) + } + + @Test + fun tokenIsSealedAndNeverLeftInPlaintext() { + val stash = stash() + stash.authToken = "smoke-token" + + assertEquals("smoke-token", stash.authToken) + assertNull(prefs.getString("discord.token", null)) + assertNotNull(prefs.getString("discord.token.enc", null)) + } + + @Test + fun clearingTokenRemovesBothStoredKeys() { + val stash = stash() + stash.authToken = "clear-me" + stash.authToken = "" + + assertFalse(stash.signedIn) + assertNull(prefs.getString("discord.token", null)) + assertNull(prefs.getString("discord.token.enc", null)) + } + + @Test + fun tokenIsReadFromStorageOnEveryAccess() { + val stash = stash() + stash.authToken = "first-token" + + // A second store instance rewrites the sealed blob; the first reads it + // fresh instead of serving a cached plaintext. + stash().authToken = "second-token" + + assertEquals("second-token", stash.authToken) + } + + @Test + fun signedInFollowsStoredBlobPresence() { + val stash = stash() + assertFalse(stash.signedIn) + + stash.authToken = "probe-token" + assertTrue(stash.signedIn) + + stash.clearToken() + assertFalse(stash.signedIn) + } + + @Test + fun clearTokenDeletesTheKeySoAnEscapedBlobStaysUnreadable() { + val stash = stash() + stash.authToken = "clear-me" + val sealed = prefs.getString("discord.token.enc", null)!! + + stash.clearToken() + + // Simulate a blob that escaped the sandbox and is put back later. + // Reading runs TokenVault.decrypt for this store's alias; it must fail + // because clearToken removed that key. + prefs.edit().putString("discord.token.enc", sealed).commit() + + assertEquals("", stash.authToken) + } + + @Test + fun corruptedBlobIsRejectedWithoutPlaintextFallback() { + assertNull(TokenVault.decrypt("not-a-blob")) + assertNull(TokenVault.decrypt("iv:not-base64!!")) + + prefs.edit().putString("discord.token.enc", "not-a-blob").commit() + val stash = stash() + + assertEquals("", stash.authToken) + assertNull(prefs.getString("discord.token", null)) + } + + @Test + fun isolatedStoreLeavesTheLiveSessionAlone() { + val live = context.getSharedPreferences("stash", Context.MODE_PRIVATE) + val sealed = live.getString("discord.token.enc", null) + + stash().authToken = "isolated-token" + + assertEquals(sealed, live.getString("discord.token.enc", null)) + } + + private companion object { + const val TEST_PREFS = "stash-test" + } +} diff --git a/app/src/androidTest/java/net/spacealtctrl/discordrp/update/UpdateIdentityTest.kt b/app/src/androidTest/java/net/spacealtctrl/discordrp/update/UpdateIdentityTest.kt new file mode 100644 index 0000000..476a65f --- /dev/null +++ b/app/src/androidTest/java/net/spacealtctrl/discordrp/update/UpdateIdentityTest.kt @@ -0,0 +1,83 @@ +package net.spacealtctrl.discordrp.update + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class UpdateIdentityTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val testContext get() = InstrumentationRegistry.getInstrumentation().context + + @After + fun removeFixtures() { + File(context.cacheDir, FIXTURE_DIR).deleteRecursively() + } + + @Test + fun acceptsTheInstalledApk() { + UpdateIdentity.verify(context, File(context.applicationInfo.sourceDir)) + } + + @Test + fun rejectsAnApkForAnotherPackage() { + expectRejected( + File(testContext.applicationInfo.sourceDir), + "package id", + ) + } + + /** + * The critical negative: the archive carries our package id but was signed + * by someone else. Rotation is deliberately not trusted here either; since + * no APK signing lineage is verified, a rotated key is indistinguishable + * from a foreign one and must fail closed. + */ + @Test + fun rejectsTheSamePackageSignedByAnotherOrRotatedKey() { + val apk = asset("update-foreign-signer.apk") + assertEquals( + context.packageName, + context.packageManager.getPackageArchiveInfo(apk.absolutePath, 0)?.packageName, + ) + expectRejected(apk, "signature") + } + + @Test + fun rejectsTheSamePackageWithoutAnySigner() { + expectRejected(asset("update-unsigned.apk"), "signature") + } + + @Test + fun rejectsTheSamePackageWithExtraSigners() { + expectRejected(asset("update-multi-signer.apk"), "signature") + } + + private fun expectRejected(apk: File, messagePart: String) { + try { + UpdateIdentity.verify(context, apk) + fail("${apk.name} must not pass update verification") + } catch (e: IllegalStateException) { + assertTrue("unexpected reason: ${e.message}", e.message.orEmpty().contains(messagePart)) + } + } + + private fun asset(name: String): File { + val dir = File(context.cacheDir, FIXTURE_DIR).apply { mkdirs() } + val file = File(dir, name) + testContext.assets.open(name).use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + return file + } + + private companion object { + const val FIXTURE_DIR = "identity-fixtures" + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f68969..1038e93 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,14 +6,14 @@ + - + @@ -26,6 +26,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name_short" android:localeConfig="@xml/locales" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher" android:supportsRtl="true" android:theme="@style/Theme.DiscordRP" @@ -58,6 +59,8 @@ android:name=".ui.crash.CrashActivity" android:exported="false" android:process=":crash" /> + + + android:exported="false"> diff --git a/app/src/main/java/net/spacealtctrl/discordrp/di/AppProviders.kt b/app/src/main/java/net/spacealtctrl/discordrp/di/AppProviders.kt index 54bc914..9c22d87 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/di/AppProviders.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/di/AppProviders.kt @@ -55,7 +55,11 @@ object AppProviders { } install(Logging) { level = if (BuildConfig.DEBUG) LogLevel.HEADERS else LogLevel.NONE - sanitizeHeader { it == HttpHeaders.Authorization } + sanitizeHeader { + it == HttpHeaders.Authorization || + it == HttpHeaders.Cookie || + it == HttpHeaders.SetCookie + } logger = object : Logger { override fun log(message: String) = log.debug("Http", message) } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/discord/DiscordApi.kt b/app/src/main/java/net/spacealtctrl/discordrp/discord/DiscordApi.kt index 2164cb5..4361f70 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/discord/DiscordApi.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/discord/DiscordApi.kt @@ -122,6 +122,11 @@ class DiscordApi @Inject constructor( } private fun HttpResponse.raiseForStatus() { + if (status.value == 401) { + // Unauthorized is permanent for this credential; drop it so the + // service cannot redial with a token Discord already rejected. + stash.clearToken() + } if (status.value !in 200..299) throw ApiRefusal(status.value) } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/discord/FirstHandshake.kt b/app/src/main/java/net/spacealtctrl/discordrp/discord/FirstHandshake.kt index d5badd8..5fc7bc9 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/discord/FirstHandshake.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/discord/FirstHandshake.kt @@ -13,7 +13,7 @@ class FirstHandshake @Inject constructor( private val stash: Stash, ) { suspend fun run(token: String): Boolean { - val probe = GatewayEngine(token = token, mobileBadge = false, autoRedial = false) + val probe = GatewayEngine(tokenProvider = { token }, mobileBadge = false, autoRedial = false) return try { val outcome = coroutineScope { val firstAnswer = async { diff --git a/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEndpoint.kt b/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEndpoint.kt new file mode 100644 index 0000000..562d0d6 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEndpoint.kt @@ -0,0 +1,41 @@ +package net.spacealtctrl.discordrp.gateway + +import java.net.URI + +/** + * Resume endpoints arrive over the gateway itself, so they are validated + * before a credential-bearing reconnect can use them. + */ +internal object GatewayEndpoint { + const val DEFAULT_URL = "wss://gateway.discord.gg/?v=10&encoding=json" + + private const val QUERY = "?v=10&encoding=json" + private const val DEFAULT_WSS_PORT = 443 + + fun resumeUrl(raw: String?): String? { + val uri = raw?.let { runCatching { URI(it) }.getOrNull() } ?: return null + if (!"wss".equals(uri.scheme, ignoreCase = true)) return null + if (uri.userInfo != null) return null + if (uri.port != -1 && uri.port != DEFAULT_WSS_PORT) return null + if (uri.rawQuery != null || uri.fragment != null) return null + + val path = uri.path.orEmpty() + if (path.isNotEmpty() && path != "/") return null + + val host = uri.host?.lowercase() ?: return null + if (!isGatewayHost(host)) return null + return "wss://$host/$QUERY" + } + + private fun isGatewayHost(host: String): Boolean { + // Anchored to Discord's own three-label hosts: gateway.discord.gg or a + // sharded name such as gateway-us-east1-b.discord.gg. Suffix checks + // alone would also accept crafted names beneath discord.gg. + val labels = host.split('.') + if (labels.size != 3) return false + if (labels[1] != "discord" || labels[2] != "gg") return false + return labels[0] == "gateway" || SHARDED_LABEL.matches(labels[0]) + } + + private val SHARDED_LABEL = Regex("gateway-[a-z0-9]+(?:-[a-z0-9]+)*") +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEngine.kt b/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEngine.kt index 6087c2c..950f7ef 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEngine.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/gateway/GatewayEngine.kt @@ -32,7 +32,7 @@ import net.spacealtctrl.discordrp.log.SilentLog import kotlin.random.Random class GatewayEngine( - private val token: String, + private val tokenProvider: () -> String, private val mobileBadge: Boolean, private val autoRedial: Boolean = true, fallbackSelfId: String? = null, @@ -231,7 +231,10 @@ class GatewayEngine( "READY" -> { val ready = GatewayCodec.read(frame) ?: return sessionId = ready.sessionId - resumeUrl = ready.resumeUrl?.let { "$it/?v=10&encoding=json" } + resumeUrl = GatewayEndpoint.resumeUrl(ready.resumeUrl) + if (ready.resumeUrl != null && resumeUrl == null) { + log.warn(TAG, "Ignoring a resume endpoint outside Discord's gateway hosts") + } ready.user?.id?.let { selfId = it } sessionUp = true _linked.value = true @@ -260,7 +263,7 @@ class GatewayEngine( transmit( Op.IDENTIFY, GatewayCodec.encode( - IdentifyRequest(token = token, properties = ClientStamp.forMobileBadge(mobileBadge)) + IdentifyRequest(token = tokenProvider(), properties = ClientStamp.forMobileBadge(mobileBadge)) ), ) } @@ -270,7 +273,7 @@ class GatewayEngine( transmit( Op.RESUME, GatewayCodec.encode( - ResumeRequest(token = token, sessionId = sessionId.orEmpty(), seq = seq) + ResumeRequest(token = tokenProvider(), sessionId = sessionId.orEmpty(), seq = seq) ), ) } @@ -324,7 +327,7 @@ class GatewayEngine( private companion object { const val TAG = "Gateway" - const val GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json" + const val GATEWAY_URL = GatewayEndpoint.DEFAULT_URL const val APP_CLOSE_CODE: Short = 4000 diff --git a/app/src/main/java/net/spacealtctrl/discordrp/log/AppLog.kt b/app/src/main/java/net/spacealtctrl/discordrp/log/AppLog.kt index b01149d..b5dbb79 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/log/AppLog.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/log/AppLog.kt @@ -14,19 +14,19 @@ object AndroidAppLog : AppLog { private const val PREFIX = "DiscordRP/" override fun debug(tag: String, message: String) { - if (BuildConfig.DEBUG) Log.d(PREFIX + tag, message) + if (BuildConfig.DEBUG) Log.d(PREFIX + tag, Redactor.redact(message)) } override fun info(tag: String, message: String) { - if (BuildConfig.DEBUG) Log.i(PREFIX + tag, message) + if (BuildConfig.DEBUG) Log.i(PREFIX + tag, Redactor.redact(message)) } override fun warn(tag: String, message: String) { - Log.w(PREFIX + tag, message) + Log.w(PREFIX + tag, Redactor.redact(message)) } override fun error(tag: String, message: String) { - Log.e(PREFIX + tag, message) + Log.e(PREFIX + tag, Redactor.redact(message)) } } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/log/Redactor.kt b/app/src/main/java/net/spacealtctrl/discordrp/log/Redactor.kt new file mode 100644 index 0000000..2b30303 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/log/Redactor.kt @@ -0,0 +1,47 @@ +package net.spacealtctrl.discordrp.log + +/** + * Every log line and crash report passes through here. Exception messages and + * headers can quote credentials or signed URLs, so the text is stripped before + * it leaves the process -- including debug builds. + */ +internal object Redactor { + private const val REPLACEMENT = "[redacted]" + + private val HEADER = Regex( + "(?i)\\b(authorization|proxy-authorization|cookie|set-cookie)\\s*:\\s*[^\\r\\n]+", + ) + private val AUTH_SCHEME = Regex("(?i)\\b(bearer|bot)\\s+[A-Za-z0-9._~+/=-]{8,}") + private val TOKEN = Regex( + "\\b(?:mfa\\.[A-Za-z0-9_-]{20,}" + + "|[A-Za-z0-9_-]{23,28}\\.[A-Za-z0-9_-]{6,7}\\.[A-Za-z0-9_-]{27,40})\\b", + ) + // Opaque credentials are long and base64-like; no message the app logs + // legitimately needs a run that long. + private val BASE64 = Regex("[A-Za-z0-9+/_-]{60,}={0,2}") + private val QUERY = Regex( + "(?i)([?&](?:" + + "token|access_token|refresh_token|id_token|client_secret|api[_-]?key|" + + "password|passwd|secret|session|session_id|code|hm|sig|signature|key|" + + "x-amz-signature|x-amz-credential|x-amz-security-token|" + + "x-goog-signature|x-goog-credential|googleaccessid|awsaccesskeyid" + + ")=)[^&#\\s'\"]+", + ) + private val FIELD = Regex( + "(?i)([\"']?(?:password|passwd|pwd|secret|token|cookie|set[_-]?cookie|" + + "authorization|client[_-]?secret|api[_-]?key|" + + "access[_-]?token|refresh[_-]?token|id[_-]?token|auth[_-]?token)" + + "[\"']?\\s*[=:]\\s*)" + + "(\"[^\"]*\"|'[^']*'|[^\\s,;&}\\)]+)", + ) + private val USERINFO = Regex("([A-Za-z][A-Za-z0-9+.-]*://)[^/?#\\s@]+@") + + fun redact(text: String): String = text + .replace(HEADER) { match -> "${match.groupValues[1]}: $REPLACEMENT" } + .replace(AUTH_SCHEME) { match -> "${match.groupValues[1]} $REPLACEMENT" } + .replace(TOKEN, "[redacted-token]") + .replace(BASE64, REPLACEMENT) + .replace(QUERY) { match -> "${match.groupValues[1]}$REPLACEMENT" } + .replace(FIELD) { match -> "${match.groupValues[1]}$REPLACEMENT" } + .replace(USERINFO) { match -> "${match.groupValues[1]}$REPLACEMENT@" } +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/service/BridgeService.kt b/app/src/main/java/net/spacealtctrl/discordrp/service/BridgeService.kt index ad3e822..da49284 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/service/BridgeService.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/service/BridgeService.kt @@ -17,6 +17,7 @@ import android.media.session.MediaController import android.media.session.MediaSessionManager import android.media.session.PlaybackState import android.os.Build +import android.os.Debug import android.os.Handler import android.os.IBinder import android.os.Looper @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import net.spacealtctrl.discordrp.BuildConfig import net.spacealtctrl.discordrp.R import net.spacealtctrl.discordrp.gateway.Gateway import net.spacealtctrl.discordrp.gateway.GatewayEngine @@ -82,11 +84,27 @@ class BridgeService : Service() { ensureStatusChannel() goForeground(statusNotification(track = null)) + // Every start path goes through startForegroundService, so the + // foreground obligation must be met before this early stop. + if (BuildConfig.DEBUG && Debug.isDebuggerConnected()) { + log.error(TAG, "Debugger attached; refusing to run the bridge") + stopSelf() + return + } + if (!stash.signedIn) { log.error(TAG, "No account on file; the bridge cannot start") stopSelf() return } + if (stash.authToken.isBlank()) { + // The sealed blob is present but cannot be opened (key lost or + // invalidated); drop it instead of dialing with an empty token. + log.error(TAG, "Stored login is unreadable; clearing it") + stash.clearToken() + stopSelf() + return + } _alive.value = true lastUpAt = System.currentTimeMillis() @@ -140,7 +158,7 @@ class BridgeService : Service() { private fun openGateway() { val engine = GatewayEngine( - token = stash.authToken, + tokenProvider = stash::authToken, mobileBadge = stash.mobileBadge, fallbackSelfId = stash.selfId.takeIf { it.isNotBlank() }, log = AndroidAppLog, @@ -173,7 +191,11 @@ class BridgeService : Service() { } private fun onAuthRejected() { - log.error(TAG, "Discord no longer accepts the stored login; stopping") + log.error(TAG, "Discord no longer accepts the stored login; clearing it and stopping") + // The close code is terminal, so the credential will never work again. + // Dropping the sealed blob keeps the app from redialing a dead token and + // makes the next launch land on the setup screen. + stash.clearToken() notificationManager().notify( STATUS_NOTICE_ID, statusBuilder() diff --git a/app/src/main/java/net/spacealtctrl/discordrp/service/StartOnBootReceiver.kt b/app/src/main/java/net/spacealtctrl/discordrp/service/StartOnBootReceiver.kt index 177cd5d..45e6706 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/service/StartOnBootReceiver.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/service/StartOnBootReceiver.kt @@ -5,7 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import android.util.Log +import net.spacealtctrl.discordrp.log.AndroidAppLog import net.spacealtctrl.discordrp.settings.Stash class StartOnBootReceiver : BroadcastReceiver() { @@ -17,16 +17,20 @@ class StartOnBootReceiver : BroadcastReceiver() { try { context.startForegroundService(Intent(context, BridgeService::class.java)) - Log.i(TAG, "Bridge relaunched after ${intent.action}") + AndroidAppLog.info(TAG, "Bridge relaunched after ${intent.action}") } catch (e: Exception) { val vetoed = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException - Log.w(TAG, if (vetoed) "System vetoed the relaunch" else "Relaunch failed", e) + AndroidAppLog.warn( + TAG, + if (vetoed) "System vetoed the relaunch" + else "Relaunch failed: ${e::class.java.simpleName}", + ) } } private companion object { - const val TAG = "DiscordRP/BootWake" + const val TAG = "BootWake" val WAKE_ACTIONS = setOf( Intent.ACTION_BOOT_COMPLETED, diff --git a/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt b/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt index f8d2c04..c351de1 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt @@ -10,9 +10,13 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.hours -class Stash private constructor(context: Context) { +class Stash private constructor( + context: Context, + prefsName: String = PREFS_NAME, + private val tokenAlias: String = TokenVault.ALIAS, +) { private val prefs: SharedPreferences = - context.applicationContext.getSharedPreferences("stash", Context.MODE_PRIVATE) + context.applicationContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } @@ -27,8 +31,51 @@ class Stash private constructor(context: Context) { } var authToken: String - get() = prefs.getString(KEY_TOKEN, null).orEmpty() - set(value) = write { putString(KEY_TOKEN, value) } + // Decrypt on demand: the plaintext must not survive in the process + // beyond the call that needs it. + get() = readToken() + set(value) { + val token = value.trim() + if (token.isEmpty()) { + clearToken() + } else { + val sealed = TokenVault.encrypt(token, tokenAlias) + write { + putString(KEY_TOKEN_ENC, sealed) + remove(KEY_TOKEN) + } + } + } + + /** + * Drops the stored credential and its Keystore key. Deleting the key first + * makes an escaped copy of the sealed blob useless even if the process dies + * before the preference write lands. + */ + fun clearToken() { + TokenVault.forget(tokenAlias) + write { + remove(KEY_TOKEN) + remove(KEY_TOKEN_ENC) + } + } + + private fun readToken(): String { + prefs.getString(KEY_TOKEN_ENC, null)?.let { sealed -> + TokenVault.decrypt(sealed, tokenAlias)?.let { return it } + } + val legacy = prefs.getString(KEY_TOKEN, null).orEmpty() + if (legacy.isBlank()) return "" + + // One-time migration from the pre-Keystore format. If sealing fails the + // plaintext copy is still dropped; a token must not stay readable on disk. + val sealed = runCatching { TokenVault.encrypt(legacy, tokenAlias) }.getOrNull() + write { + if (sealed != null) putString(KEY_TOKEN_ENC, sealed) + remove(KEY_TOKEN) + } + return if (sealed != null) legacy else "" + } var selfId: String get() = prefs.getString(KEY_SELF_ID, null).orEmpty() @@ -46,7 +93,11 @@ class Stash private constructor(context: Context) { get() = prefs.getString(KEY_PROFILE_CACHE, null).orEmpty() set(value) = write { putString(KEY_PROFILE_CACHE, value) } - val signedIn: Boolean get() = authToken.isNotBlank() + // Presence check only: reading the sealed blob is enough to tell the UI + // whether a session exists, and avoids decrypting on every recomposition. + val signedIn: Boolean + get() = prefs.contains(KEY_TOKEN_ENC) || + !prefs.getString(KEY_TOKEN, null).isNullOrBlank() var presenceEnabled: Boolean get() = prefs.getBoolean(KEY_PRESENCE_ON, true) @@ -205,7 +256,16 @@ class Stash private constructor(context: Context) { shared ?: Stash(context).also { shared = it } } + /** + * A store that does not share preferences with the signed-in app. Tests + * use it so a run can never clobber the developer's live session. + */ + internal fun isolated(context: Context, prefsName: String): Stash = + Stash(context, prefsName, "${TokenVault.ALIAS}.$prefsName") + + private const val PREFS_NAME = "stash" private const val KEY_TOKEN = "discord.token" + private const val KEY_TOKEN_ENC = "discord.token.enc" private const val KEY_SELF_ID = "discord.user.id" private const val KEY_SELF_BIO = "discord.user.bio" private const val KEY_SELF_NITRO = "discord.user.nitro" diff --git a/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt b/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt new file mode 100644 index 0000000..e5d0eff --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt @@ -0,0 +1,120 @@ +package net.spacealtctrl.discordrp.settings + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Seals the Discord token with an Android Keystore key. + * + * The key is deliberately not user-authentication bound and not tied to the + * unlocked-device state: the gateway service has to read the token while the + * screen is off. `EncryptedSharedPreferences` would add a second wrapping layer + * without changing that property, so the vault is the single, documented story + * for token storage. The sealed blob lives in private preferences, which the + * backup rules exclude. + * + * Plaintext is held only for the duration of one call. Byte buffers used to + * move it through the cipher are zeroed afterwards; Kotlin strings themselves + * cannot be wiped, so the callers keep the returned value short-lived. + */ +internal object TokenVault { + /** Alias used by the signed-in app. Test stores derive their own alias. */ + const val ALIAS = "discordrp.token.v1" + + private const val KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_BITS = 128 + private const val KEY_BITS = 256 + + @Synchronized + fun encrypt(plaintext: String, alias: String = ALIAS): String { + val bytes = plaintext.toByteArray(Charsets.UTF_8) + try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key(alias)) + val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + val body = Base64.encodeToString(cipher.doFinal(bytes), Base64.NO_WRAP) + return "$iv:$body" + } finally { + bytes.fill(0) + } + } + + @Synchronized + fun decrypt(sealed: String, alias: String = ALIAS): String? = try { + val parts = sealed.split(':', limit = 2) + if (parts.size != 2) { + null + } else { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + key(alias), + GCMParameterSpec(TAG_BITS, Base64.decode(parts[0], Base64.NO_WRAP)), + ) + val plain = cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)) + try { + String(plain, Charsets.UTF_8) + } finally { + plain.fill(0) + } + } + } catch (_: Exception) { + // A missing or invalidated key means the stored token is unusable; the + // user has to sign in again rather than fall back to plaintext. + null + } + + /** + * Drops the key. Called whenever the stored credential is discarded, so + * even a sealed blob that escaped the app sandbox (backup, extracted file) + * cannot be opened again. + */ + @Synchronized + fun forget(alias: String = ALIAS) { + runCatching { + KeyStore.getInstance(KEYSTORE).apply { load(null) }.deleteEntry(alias) + } + } + + private fun key(alias: String): SecretKey { + val store = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (store.getKey(alias, null) as? SecretKey)?.let { return it } + return generate(alias) + } + + private fun generate(alias: String): SecretKey { + // Hardware-backed keys when the device offers them; the fallback keeps + // the app working where StrongBox is absent or already occupied. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + runCatching { generate(alias, strongBox = true) }.getOrNull()?.let { return it } + } + return generate(alias, strongBox = false) + } + + private fun generate(alias: String, strongBox: Boolean): SecretKey { + val builder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_BITS) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init(builder.build()) + return generator.generateKey() + } +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/crash/CrashGuard.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/crash/CrashGuard.kt index c14416f..c55abad 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/crash/CrashGuard.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/crash/CrashGuard.kt @@ -3,6 +3,7 @@ package net.spacealtctrl.discordrp.ui.crash import android.content.Context import android.content.Intent import android.os.Build +import net.spacealtctrl.discordrp.log.Redactor import java.io.PrintWriter import java.io.StringWriter import kotlin.system.exitProcess @@ -27,6 +28,7 @@ object CrashGuard { private fun describe(context: Context, error: Throwable): String { val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString() + val redacted = Redactor.redact(trace) val version = runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull() ?: "?" @@ -36,7 +38,7 @@ object CrashGuard { |Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT}) |App version: $version | - |$trace + |$redacted """.trimMargin().take(REPORT_CAP) } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt index 42c4056..7761fc0 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginScreen.kt @@ -10,6 +10,10 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,25 +24,38 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -88,9 +105,14 @@ class LoginViewModel @Inject constructor( fun onToken(token: String) { if (_stage.value == LoginStage.Verifying) return + if (!TokenShape.isPlausible(token)) { + _stage.value = LoginStage.Failed + return + } _stage.value = LoginStage.Verifying viewModelScope.launch { - _stage.value = if (handshake.run(token)) LoginStage.Done else LoginStage.Failed + val accepted = runCatching { handshake.run(token) }.getOrDefault(false) + _stage.value = if (accepted) LoginStage.Done else LoginStage.Failed } } } @@ -103,7 +125,17 @@ fun LoginScreen( viewModel: LoginViewModel = hiltViewModel(), ) { val stage by viewModel.stage.collectAsState() + val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var manualTokenOpen by remember { mutableStateOf(false) } + var manualToken by remember { mutableStateOf("") } + var manualTokenVisible by remember { mutableStateOf(false) } + + fun closeManualToken() { + manualTokenOpen = false + manualToken = "" + manualTokenVisible = false + } LaunchedEffect(stage) { if (stage == LoginStage.Done) onDone() @@ -231,6 +263,21 @@ fun LoginScreen( ) } } + + TextButton( + onClick = { + manualToken = "" + manualTokenVisible = false + manualTokenOpen = true + }, + enabled = stage != LoginStage.Verifying, + ) { + Text( + text = stringResource(R.string.login_token_manual), + style = MaterialTheme.typography.labelLarge, + color = Look.palette.inkFaint, + ) + } } } @@ -243,5 +290,98 @@ fun LoginScreen( TokenCapture(onToken = viewModel::onToken) } } + + if (manualTokenOpen) { + val cleanToken = manualToken.trim().removeSurrounding("\"").trim() + AlertDialog( + onDismissRequest = ::closeManualToken, + title = { Text(stringResource(R.string.login_token_manual_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.login_token_manual_help), + style = MaterialTheme.typography.bodySmall, + color = Look.palette.inkFaint, + ) + OutlinedTextField( + value = manualToken, + onValueChange = { manualToken = it.trim() }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), + label = { Text(stringResource(R.string.login_token_field_label)) }, + visualTransformation = if (manualTokenVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton( + onClick = { manualTokenVisible = !manualTokenVisible }, + ) { + Icon( + imageVector = if (manualTokenVisible) { + Icons.Rounded.VisibilityOff + } else { + Icons.Rounded.Visibility + }, + contentDescription = stringResource( + if (manualTokenVisible) { + R.string.login_token_hide + } else { + R.string.login_token_show + }, + ), + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = Look.gaps.snug), + ) + } + }, + confirmButton = { + TextButton( + enabled = cleanToken.isNotEmpty() && stage != LoginStage.Verifying, + onClick = { + closeManualToken() + clearClipboardIfMatches(context, cleanToken) + viewModel.onToken(cleanToken) + }, + ) { + Text(stringResource(R.string.login_token_manual_submit)) + } + }, + dismissButton = { + TextButton(onClick = ::closeManualToken) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } + } +} + +/** + * A pasted token is a credential left behind in the clipboard. Clear it once + * the app has its copy, but only when the clipboard still holds exactly what + * was pasted. + */ +private fun clearClipboardIfMatches(context: Context, token: String) { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return + val clip = clipboard.primaryClip ?: return + if (clip.itemCount == 0) return + val copied = clip.getItemAt(0).text?.toString() + ?.trim()?.removeSurrounding("\"")?.trim() + if (copied != token) return + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + clipboard.clearPrimaryClip() + } else { + clipboard.setPrimaryClip(ClipData.newPlainText("", "")) + } } } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginWebPolicy.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginWebPolicy.kt new file mode 100644 index 0000000..c0ec63f --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/LoginWebPolicy.kt @@ -0,0 +1,29 @@ +package net.spacealtctrl.discordrp.ui.screens.login + +import java.net.URI + +/** + * Trust boundary for the embedded Discord login page. Only canonical HTTPS + * Discord origins can load in the login WebView, and only /app may trigger + * token extraction. + */ +internal object LoginWebPolicy { + private const val DEFAULT_HTTPS_PORT = 443 + + fun isDiscordUrl(raw: String?): Boolean { + val uri = raw?.let { runCatching { URI(it) }.getOrNull() } ?: return false + if (!"https".equals(uri.scheme, ignoreCase = true)) return false + if (uri.userInfo != null) return false + if (uri.port != -1 && uri.port != DEFAULT_HTTPS_PORT) return false + val host = uri.host?.lowercase() ?: return false + // Only the origin that serves the login application. Legacy + // discordapp.com links 301 to the same place, and neither the login page nor + // extraction needs any other Discord origin. + return host == "discord.com" + } + + fun isDiscordAppUrl(raw: String?): Boolean { + val uri = raw?.let { runCatching { URI(it) }.getOrNull() } ?: return false + return isDiscordUrl(raw) && uri.path?.trimEnd('/') == "/app" + } +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/SessionCleanup.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/SessionCleanup.kt new file mode 100644 index 0000000..3703610 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/SessionCleanup.kt @@ -0,0 +1,149 @@ +package net.spacealtctrl.discordrp.ui.screens.login + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The operations a login WebView needs for teardown, split out so the ordering + * can be exercised without an Android runtime. + */ +internal interface SessionOps { + fun stopPage() + + fun loadBlank() + + /** Detaches the view; [destroyPage] refuses to run while it is attached. */ + fun detach() + + fun clearStorage() + + /** Must call [onDone] only after the cookie store is actually gone. */ + fun removeCookies(onDone: () -> Unit) + + fun flushCookies() + + fun clearCache() + + fun destroyPage() +} + +/** + * Tears a login WebView down in an order that cannot leak the document back: + * stop loading, replace the document with about:blank, wait until that + * navigation has finished (the old document is gone once it commits), wipe + * storage, and only after [SessionOps.removeCookies] reports completion flush + * the cookie store and consider the session cleaned. + * + * If the blank navigation never commits, the timeout path fails closed instead + * of pretending the commit happened: the view is detached and destroyed (which + * stops the still-live document) and only then are storage and cookies wiped. + * That path never delivers a capture callback. + * + * Both the successful capture path and the cancel path go through the same + * instance, so the work happens once. Destruction is deferred until cleanup is + * complete even when the composable is released first. + */ +internal class SessionCleanup( + private val ops: SessionOps, + private val scheduleTimeout: (action: () -> Unit) -> Unit, +) { + private val started = AtomicBoolean(false) + private var clearing = false + private var complete = false + private var destroyWhenComplete = false + private var destroyed = false + private var cancelled = false + private var onCleaned: (() -> Unit)? = null + + /** True once capture is cancelled or the fail-closed timeout has run. */ + val isCancelled: Boolean get() = cancelled + + fun start(onCleaned: (() -> Unit)? = null) { + // A capture callback arriving after cancellation must never be accepted; + // the token may have been read from a document that was never torn down. + if (cancelled) return + if (onCleaned != null) this.onCleaned = onCleaned + if (complete) { + deliver() + return + } + begin() + } + + fun cancel() { + cancelled = true + onCleaned = null + release() + } + + fun release() { + destroyWhenComplete = true + if (complete) { + destroy() + return + } + begin() + } + + /** Called for every page finish; only the blank document starts cleanup. */ + fun onPageFinished(url: String) { + if (url == BLANK_URL) clear() + } + + private fun begin() { + if (!started.compareAndSet(false, true)) return + ops.stopPage() + ops.loadBlank() + scheduleTimeout { failClosed() } + } + + private fun clear() { + if (clearing || complete) return + clearing = true + ops.clearStorage() + ops.removeCookies { + if (complete) return@removeCookies + ops.flushCookies() + complete = true + ops.clearCache() + deliver() + if (destroyWhenComplete) destroy() + } + } + + /** + * The blank document never committed, so the previous document may still be + * executing and could write the token back after a wipe. Destroying the view + * stops it; only then is storage safe to clear. + */ + private fun failClosed() { + if (complete || clearing) return + clearing = true + cancelled = true + onCleaned = null + ops.detach() + destroy() + ops.clearStorage() + ops.removeCookies { + if (complete) return@removeCookies + ops.flushCookies() + complete = true + } + } + + private fun deliver() { + if (cancelled) return + val callback = onCleaned + onCleaned = null + callback?.invoke() + } + + private fun destroy() { + if (destroyed) return + destroyed = true + ops.destroyPage() + } + + companion object { + const val BLANK_URL = "about:blank" + } +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt index 93392a0..f11b9de 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenCapture.kt @@ -2,70 +2,213 @@ package net.spacealtctrl.discordrp.ui.screens.login import android.annotation.SuppressLint import android.os.Build +import android.os.Handler +import android.os.Looper import android.view.View import android.view.ViewGroup -import android.webkit.JsResult -import android.webkit.WebChromeClient +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.viewinterop.AndroidView +import org.json.JSONTokener +import java.util.concurrent.atomic.AtomicReference private const val LOGIN_URL = "https://discord.com/login" -private const val TOKEN_HOOK = - "javascript:(function()%7Bvar%20i%3Ddocument.createElement('iframe')%3B" + - "document.body.appendChild(i)%3B" + - "alert(i.contentWindow.localStorage.token.slice(1,-1))%7D)()" +// Discord's login page removes the storage API from the top window once its +// scripts run, but a same-origin iframe still reaches the same storage area. +// Read through the iframe and drop it again in the same turn. +private const val TOKEN_READ = + "(function(){var f=null;try{" + + "f=document.createElement('iframe');" + + "f.style.display='none';" + + "(document.documentElement||document.body).appendChild(f);" + + "var t=f.contentWindow.localStorage.getItem('token');" + + "if(!t)return '';" + + "try{return JSON.parse(t)}catch(e){return t}" + + "}catch(e){return ''}" + + "finally{if(f&&f.parentNode)f.parentNode.removeChild(f)}" + + "})()" + +private const val CAPTURE_INTERVAL_MS = 500L +private const val CAPTURE_WINDOW_MS = 20_000L + +// A blank document commits in milliseconds; the timeout only exists so a +// wedged renderer cannot keep the storage wipe from ever running. +private const val CLEANUP_TIMEOUT_MS = 3_000L private const val FALLBACK_UA = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.363" +private fun tokenFromEvaluation(raw: String?): String? { + if (raw.isNullOrBlank() || raw == "null") return null + val value = runCatching { JSONTokener(raw).nextValue() as? String }.getOrNull() + ?: return null + return value.trim().removeSurrounding("\"").trim().takeIf { it.isNotEmpty() } +} + +private class WebViewSessionOps( + private val webView: WebView, + private val handler: Handler, +) : SessionOps { + override fun stopPage() { + webView.stopLoading() + } + + override fun loadBlank() { + webView.loadUrl(SessionCleanup.BLANK_URL) + } + + override fun detach() { + (webView.parent as? ViewGroup)?.removeView(webView) + } + + override fun clearStorage() { + // The WebView keeps a second copy of the token in localStorage while the + // user is signed in. The blank document has committed by now, so no + // Discord script is left to write it back. + WebStorage.getInstance().deleteAllData() + } + + override fun removeCookies(onDone: () -> Unit) { + CookieManager.getInstance().removeAllCookies { handler.post(onDone) } + } + + override fun flushCookies() { + CookieManager.getInstance().flush() + } + + override fun clearCache() { + webView.clearCache(true) + webView.clearHistory() + webView.clearFormData() + } + + override fun destroyPage() { + webView.destroy() + } +} + @SuppressLint("SetJavaScriptEnabled") @Composable fun TokenCapture(onToken: (String) -> Unit) { + val sessionRef = remember { AtomicReference(null) } AndroidView( onRelease = { webView -> - webView.stopLoading() - webView.destroy() + // The composable owns the view until here; detaching first keeps a + // deferred destroy legal. + (webView.parent as? ViewGroup)?.removeView(webView) + sessionRef.get()?.cancel() }, factory = { context -> - WebView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, + val handler = Handler(Looper.getMainLooper()) + val webView = WebView(context) + val session = SessionCleanup( + ops = WebViewSessionOps(webView, handler), + scheduleTimeout = { action -> handler.postDelayed(action, CLEANUP_TIMEOUT_MS) }, ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - if (Build.MANUFACTURER.equals("motorola", ignoreCase = true)) { - settings.userAgentString = FALLBACK_UA - } + sessionRef.set(session) + webView.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + settings.javaScriptCanOpenWindowsAutomatically = false + settings.setSupportMultipleWindows(false) + settings.setGeolocationEnabled(false) + settings.mediaPlaybackRequiresUserGesture = true + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + if (Build.MANUFACTURER.equals("motorola", ignoreCase = true)) { + settings.userAgentString = FALLBACK_UA + } + + webViewClient = object : WebViewClient() { + private var capturing = false + + private fun capture(view: WebView, url: String?) { + if (session.isCancelled) return + if (capturing || !LoginWebPolicy.isDiscordAppUrl(url)) return + capturing = true + val deadline = System.currentTimeMillis() + CAPTURE_WINDOW_MS + + // The token lands in storage a moment after Discord + // starts navigating to /app, so keep reading until the + // deadline instead of sampling a fixed number of times. + fun read() { + view.evaluateJavascript(TOKEN_READ) { result -> + if (session.isCancelled) return@evaluateJavascript + val token = tokenFromEvaluation(result) + if (token != null && !session.isCancelled && + LoginWebPolicy.isDiscordUrl(view.url) + ) { + // Hide the page and hand it to the cleanup + // sequence: it stops the page, swaps in + // about:blank, waits for that commit, then + // wipes storage and awaits cookie removal + // before the token is accepted. + view.visibility = View.GONE + session.start { onToken(token) } + return@evaluateJavascript + } + if (System.currentTimeMillis() < deadline && + LoginWebPolicy.isDiscordUrl(view.url) + ) { + view.postDelayed({ + read() + }, CAPTURE_INTERVAL_MS) + } else { + capturing = false + } + } + } - webViewClient = object : WebViewClient() { - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { - stopLoading() - if (url.endsWith("/app")) { - loadUrl(TOKEN_HOOK) - visibility = View.GONE + read() + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + if (!request.isForMainFrame) return false + val url = request.url.toString() + if (!LoginWebPolicy.isDiscordUrl(url)) return true + if (LoginWebPolicy.isDiscordAppUrl(url)) { + view.stopLoading() + capture(view, url) + return true + } + return false + } + + // The first composited frame can stay white while the bottom + // sheet animates in; repaint once the page is committed. + override fun onPageCommitVisible(view: WebView, url: String) { + super.onPageCommitVisible(view, url) + view.postInvalidateOnAnimation() + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + view.post { + view.requestLayout() + view.invalidate() + } + session.onPageFinished(url) + capture(view, url) } - return false - } - } - webChromeClient = object : WebChromeClient() { - override fun onJsAlert( - view: WebView, - url: String, - message: String, - result: JsResult, - ): Boolean { - onToken(message) - visibility = View.GONE - return true } + loadUrl(LOGIN_URL) } - loadUrl(LOGIN_URL) - } - }) + }, + ) } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenShape.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenShape.kt new file mode 100644 index 0000000..4783625 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/login/TokenShape.kt @@ -0,0 +1,15 @@ +package net.spacealtctrl.discordrp.ui.screens.login + +/** + * Discord tokens are opaque, but they are always a compact run of URL-safe + * characters. Rejecting obvious garbage before a handshake keeps pasted text + * from being sent to Discord or stored. + */ +internal object TokenShape { + private val SHAPE = Regex("[A-Za-z0-9._-]{20,200}") + + fun isPlausible(raw: String?): Boolean { + val value = raw?.trim()?.removeSurrounding("\"")?.trim() ?: return false + return SHAPE.matches(value) + } +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/you/YouViewModel.kt b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/you/YouViewModel.kt index 1376962..262d4ba 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/you/YouViewModel.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/ui/screens/you/YouViewModel.kt @@ -2,6 +2,8 @@ package net.spacealtctrl.discordrp.ui.screens.you import android.app.ActivityManager import android.content.Context +import android.webkit.CookieManager +import android.webkit.WebStorage import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -66,6 +68,15 @@ class YouViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), YouUiState()) fun signOut() { + // clearApplicationUserData removes the sealed blob with the rest of the + // sandbox; clearToken drops the Keystore key and WebView copies first so + // neither survives even if the data wipe is interrupted. + runCatching { + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + WebStorage.getInstance().deleteAllData() + } + stash.clearToken() appContext.getSystemService(ActivityManager::class.java) ?.clearApplicationUserData() } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt index 4e5c687..19dbb19 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt @@ -12,6 +12,7 @@ import io.ktor.client.request.header import io.ktor.client.request.prepareGet import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode import io.ktor.utils.io.readAvailable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -29,10 +30,11 @@ import kotlinx.serialization.Serializable import net.spacealtctrl.discordrp.BuildConfig import net.spacealtctrl.discordrp.log.AppLog import java.io.File +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton -data class AppUpdate(val version: String, val apkUrl: String) +data class AppUpdate(val version: String, val apkUrl: String, val sha256: String) sealed interface UpdateState { data object Idle : UpdateState @@ -95,15 +97,33 @@ class UpdateHub @Inject constructor( } private suspend fun fetchLatest(): UpdateState = try { - val release: FeedRelease = http.get(FEED_URL) { + val response = http.get(UpdatePolicy.FEED_URL) { header(HttpHeaders.Accept, "application/vnd.github+json") - }.body() + } + if (response.status == HttpStatusCode.NotFound) return UpdateState.UpToDate + val release: FeedRelease = response.body() val version = release.tagName.removePrefix("v") val apk = release.assets.firstOrNull { it.name.endsWith(".apk") } + ?: return UpdateState.UpToDate + if (!UpdatePolicy.isTrustedApkUrl(apk.downloadUrl)) { + log.warn(TAG, "Release asset is not on the expected GitHub path") + return UpdateState.Failed + } when { - apk == null -> UpdateState.UpToDate - VersionOrder.isNewer(version, BuildConfig.VERSION_NAME) -> - UpdateState.Available(AppUpdate(version, apk.downloadUrl)) + VersionOrder.isNewer(version, BuildConfig.VERSION_NAME) -> { + val digest = UpdatePolicy.sha256(apk.digest) + if (digest == null) { + log.warn(TAG, "Release asset has no valid SHA-256 digest") + return UpdateState.Failed + } + UpdateState.Available( + AppUpdate( + version = version, + apkUrl = apk.downloadUrl, + sha256 = digest, + ), + ) + } else -> UpdateState.UpToDate } } catch (e: CancellationException) { @@ -114,25 +134,56 @@ class UpdateHub @Inject constructor( } private suspend fun download(update: AppUpdate): File { + // Rechecked here as well as when the feed was parsed: this is the point + // where the URL turns into a network request. + if (!UpdatePolicy.isTrustedApkUrl(update.apkUrl)) error("untrusted update url") val dir = File(context.cacheDir, UPDATES_DIR).apply { mkdirs() } - val file = File(dir, "DiscordRP-${update.version}.apk") - http.prepareGet(update.apkUrl) { - timeout { requestTimeoutMillis = DOWNLOAD_TIMEOUT_MS } - }.execute { response -> - if (response.status.value !in 200..299) { - error("download answered ${response.status.value}") - } - file.outputStream().use { out -> - val channel = response.bodyAsChannel() - val buffer = ByteArray(DOWNLOAD_CHUNK_BYTES) - while (true) { - val read = channel.readAvailable(buffer, 0, buffer.size) - if (read == -1) break - if (read > 0) out.write(buffer, 0, read) + val safeVersion = update.version.replace(Regex("[^A-Za-z0-9._-]"), "_") + val file = File(dir, "DiscordRP-$safeVersion.apk") + try { + val digest = MessageDigest.getInstance("SHA-256") + var written = 0L + http.prepareGet(update.apkUrl) { + timeout { requestTimeoutMillis = DOWNLOAD_TIMEOUT_MS } + }.execute { response -> + if (response.status.value !in 200..299) { + error("download answered ${response.status.value}") + } + val resolved = response.call.request.url.toString() + if (!UpdatePolicy.isTrustedDownloadUrl(update.apkUrl, resolved)) { + error("download redirected to an untrusted host") + } + val declared = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (declared != null && (declared < 0 || declared > MAX_APK_BYTES)) { + error("update size is outside the accepted range") + } + file.outputStream().use { out -> + val channel = response.bodyAsChannel() + val buffer = ByteArray(DOWNLOAD_CHUNK_BYTES) + while (true) { + val read = channel.readAvailable(buffer, 0, buffer.size) + if (read == -1) break + if (read > 0) { + written += read + if (written > MAX_APK_BYTES) { + error("update size is outside the accepted range") + } + out.write(buffer, 0, read) + digest.update(buffer, 0, read) + } + } } } + val expected = update.sha256 + val actual = digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) } + if (actual != expected) error("download digest mismatch") + UpdateIdentity.verify(context, file) + return file + } catch (e: Throwable) { + // No partial file may survive a failed or cancelled download. + file.delete() + throw e } - return file } private fun launchInstaller(apk: File) { @@ -148,6 +199,7 @@ class UpdateHub @Inject constructor( private data class FeedAsset( val name: String, @SerialName("browser_download_url") val downloadUrl: String, + val digest: String? = null, ) @Serializable @@ -159,12 +211,10 @@ class UpdateHub @Inject constructor( private companion object { const val TAG = "UpdateHub" - const val FEED_URL = - "https://api.github.com/repos/spacealtctrl/DiscordRP/releases/latest" - const val UPDATES_DIR = "updates" const val AUTO_CHECK_COOLDOWN_MS = 6 * 60 * 60 * 1000L const val DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000L const val DOWNLOAD_CHUNK_BYTES = 64 * 1024 + const val MAX_APK_BYTES = 100L * 1024 * 1024 } } diff --git a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateIdentity.kt b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateIdentity.kt new file mode 100644 index 0000000..24cddd0 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateIdentity.kt @@ -0,0 +1,74 @@ +package net.spacealtctrl.discordrp.update + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import java.io.File +import java.security.MessageDigest + +/** + * An update must be the same app, signed by the same key that installed the + * running build. Android enforces that at install time; checking it before + * handing the file to the installer keeps a foreign or re-signed package from + * ever being presented as an update, and turns the failure into a clear one. + */ +internal object UpdateIdentity { + fun verify(context: Context, apk: File) { + val pm = context.packageManager + val archive = pm.getPackageArchiveInfo(apk.absolutePath, signingFlags) + if (archive == null) { + // Some devices refuse to parse an archive at all when signatures are + // requested and the APK has none. Confirm the package if the file is + // otherwise readable, then fail on identity. + val readable = pm.getPackageArchiveInfo(apk.absolutePath, 0) + ?: error("unreadable update package") + if (readable.packageName != context.packageName) error("update package id mismatch") + error("update signature mismatch") + } + if (archive.packageName != context.packageName) { + error("update package id mismatch") + } + + val candidate = signerDigests(archive) + val installed = signerDigests(pm.getPackageInfo(context.packageName, signingFlags)) + // Signing-key rotation is intentionally not accepted. Verifying a + // lineage would let a rotated key pass, but without checking the APK's + // lineage against the installed certificate history a rotated key looks + // exactly like a foreign one, so identity failures stay fail-closed. + if (candidate.isEmpty() || installed.isEmpty() || candidate != installed) { + error("update signature mismatch") + } + } + + private val signingFlags: Int + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Some devices leave signingInfo unset when parsing an archive, so + // ask for the legacy field as well and fall back to it below. + PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_SIGNATURES + } + + private fun signerDigests(info: PackageInfo): Set { + val signers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + signersP(info) + } else { + legacySignatures(info) + }.orEmpty() + if (signers.isEmpty()) return emptySet() + return signers.mapTo(mutableSetOf()) { signature -> + MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun signersP(info: PackageInfo) = info.signingInfo?.apkContentsSigners + + @Suppress("DEPRECATION") + private fun legacySignatures(info: PackageInfo) = info.signatures +} diff --git a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdatePolicy.kt b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdatePolicy.kt new file mode 100644 index 0000000..d723746 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdatePolicy.kt @@ -0,0 +1,57 @@ +package net.spacealtctrl.discordrp.update + +import java.net.URI + +/** + * Updates are distributed from the project's GitHub releases. The downloaded + * APK must also carry the SHA-256 digest published with the asset. + */ +internal object UpdatePolicy { + const val REPO = "spacealtctrl/DiscordRP" + const val FEED_URL = "https://api.github.com/repos/$REPO/releases/latest" + + private const val ASSET_PREFIX = "/$REPO/releases/download/" + private const val DEFAULT_HTTPS_PORT = 443 + private val SHA256 = Regex("[0-9a-f]{64}") + + // GitHub answers release-asset downloads with a redirect to a signed, + // short-lived object URL. Both hosts are first-party. + private val REDIRECT_HOSTS = setOf( + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", + ) + + fun isTrustedApkUrl(raw: String?): Boolean { + val uri = raw?.let { runCatching { URI(it) }.getOrNull() } ?: return false + if (!"https".equals(uri.scheme, ignoreCase = true)) return false + if (!"github.com".equals(uri.host, ignoreCase = true)) return false + if (uri.userInfo != null) return false + if (uri.port != -1 && uri.port != DEFAULT_HTTPS_PORT) return false + if (uri.rawQuery != null || uri.fragment != null) return false + return uri.path?.startsWith(ASSET_PREFIX) == true + } + + /** + * Rechecked at download time. [resolved] is the URL the HTTP client actually + * ends up on after redirects; a redirect may only land on GitHub's signed + * asset host, and a direct answer must still be the trusted asset URL. + */ + fun isTrustedDownloadUrl(initial: String?, resolved: String?): Boolean { + if (!isTrustedApkUrl(initial)) return false + val uri = resolved?.let { runCatching { URI(it) }.getOrNull() } ?: return false + if (!"https".equals(uri.scheme, ignoreCase = true)) return false + if (uri.userInfo != null) return false + if (uri.port != -1 && uri.port != DEFAULT_HTTPS_PORT) return false + if (uri.fragment != null) return false + return when (uri.host?.lowercase()) { + "github.com" -> isTrustedApkUrl(resolved) + in REDIRECT_HOSTS -> true + else -> false + } + } + + fun sha256(raw: String?): String? { + val value = raw?.removePrefix("sha256:")?.trim()?.lowercase() ?: return null + return value.takeIf { SHA256.matches(it) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74c04e4..bf587da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -134,6 +134,13 @@ You sign in on Discord\'s own page. Your login is stored only on this device. Sign in failed Discord did not accept the login. Try again. + Use an existing token + Use an existing token + On a computer signed in to Discord, open DevTools → Application → Local Storage → discord.com and copy the token value here. It stays on this device. + Token + Show token + Hide token + Sign in Back Cancel OK diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..845156f --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/app/src/test/java/net/spacealtctrl/discordrp/gateway/GatewayEndpointTest.kt b/app/src/test/java/net/spacealtctrl/discordrp/gateway/GatewayEndpointTest.kt new file mode 100644 index 0000000..4c2cce4 --- /dev/null +++ b/app/src/test/java/net/spacealtctrl/discordrp/gateway/GatewayEndpointTest.kt @@ -0,0 +1,48 @@ +package net.spacealtctrl.discordrp.gateway + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GatewayEndpointTest { + @Test + fun acceptsDiscordGatewayHostsAndAddsTheProtocolQuery() { + assertEquals( + "wss://gateway.discord.gg/?v=10&encoding=json", + GatewayEndpoint.resumeUrl("wss://gateway.discord.gg"), + ) + assertEquals( + "wss://gateway.discord.gg/?v=10&encoding=json", + GatewayEndpoint.resumeUrl("wss://gateway.discord.gg/"), + ) + assertEquals( + "wss://gateway-us-east1-b.discord.gg/?v=10&encoding=json", + GatewayEndpoint.resumeUrl("wss://gateway-us-east1-b.discord.gg"), + ) + assertEquals( + "wss://gateway.discord.gg/?v=10&encoding=json", + GatewayEndpoint.resumeUrl("wss://GATEWAY.DISCORD.GG:443"), + ) + } + + @Test + fun rejectsEndpointsThatCouldReceiveTheCredential() { + assertNull(GatewayEndpoint.resumeUrl(null)) + assertNull(GatewayEndpoint.resumeUrl("")) + assertNull(GatewayEndpoint.resumeUrl("ws://gateway.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("https://gateway.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://evil.test")) + assertNull(GatewayEndpoint.resumeUrl("wss://discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg.evil.test")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg.evil.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.attacker.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://gatewayevil.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway-.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg.")) + assertNull(GatewayEndpoint.resumeUrl("wss://user@gateway.discord.gg")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg:8443")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg/socket")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg?v=10")) + assertNull(GatewayEndpoint.resumeUrl("wss://gateway.discord.gg/#fragment")) + } +} diff --git a/app/src/test/java/net/spacealtctrl/discordrp/log/RedactorTest.kt b/app/src/test/java/net/spacealtctrl/discordrp/log/RedactorTest.kt new file mode 100644 index 0000000..18e3f82 --- /dev/null +++ b/app/src/test/java/net/spacealtctrl/discordrp/log/RedactorTest.kt @@ -0,0 +1,131 @@ +package net.spacealtctrl.discordrp.log + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RedactorTest { + @Test + fun removesTokenShapedValues() { + val token = "${"M".repeat(24)}.${"Ga4BcD"}.${"a".repeat(38)}" + val mfa = "mfa.${"A".repeat(84)}" + + val redacted = Redactor.redact("token=$token\nmfa=$mfa") + assertFalse(redacted.contains(token)) + assertFalse(redacted.contains(mfa)) + assertTrue(redacted.contains("[redacted-token]")) + } + + @Test + fun removesCredentialHeaders() { + val report = """ + Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature + Cookie: session=abc123; discord_token=secret + Accept: application/json + """.trimIndent() + + val redacted = Redactor.redact(report) + assertFalse(redacted.contains("eyJhbGciOiJIUzI1NiJ9")) + assertFalse(redacted.contains("session=abc123")) + assertTrue(redacted.contains("Authorization: [redacted]")) + assertTrue(redacted.contains("Cookie: [redacted]")) + assertTrue(redacted.contains("Accept: application/json")) + } + + @Test + fun removesAuthenticatedUrlParts() { + val redacted = Redactor.redact( + "GET https://cdn.discordapp.com/a/b.png?ex=1&is=2&hm=deadbeef " + + "https://user:pass@discord.com/app?token=abc", + ) + assertFalse(redacted.contains("deadbeef")) + assertFalse(redacted.contains("token=abc")) + assertFalse(redacted.contains("user:pass")) + assertTrue(redacted.contains("ex=1&is=2")) + assertTrue(redacted.contains("https://[redacted]@discord.com/app")) + } + + @Test + fun removesAwsAndGoogleSignedUrlSecrets() { + val report = + "GET https://bucket.s3.amazonaws.com/a?X-Amz-Signature=deadbeef" + + "&X-Amz-Credential=AKIAEXAMPLE&X-Amz-Security-Token=secretvalue" + + "&X-Amz-Date=20260101T000000Z\n" + + "GET https://storage.googleapis.com/b?X-Goog-Signature=cafe" + + "&X-Goog-Credential=svc%2F20260101&GoogleAccessId=svc@example.test&Expires=1" + + val redacted = Redactor.redact(report) + assertFalse(redacted.contains("deadbeef")) + assertFalse(redacted.contains("AKIAEXAMPLE")) + assertFalse(redacted.contains("secretvalue")) + assertFalse(redacted.contains("cafe")) + assertFalse(redacted.contains("svc@example.test")) + assertTrue(redacted.contains("X-Amz-Date=20260101T000000Z")) + assertTrue(redacted.contains("Expires=1")) + } + + @Test + fun redactsStructuredCredentialFields() { + val report = """ + {"password":"hunter2","clientSecret":"abc123","api_key":"k-1"} + client_secret=shh + PASSWORD: swordfish + """.trimIndent() + + val redacted = Redactor.redact(report) + assertFalse(redacted.contains("hunter2")) + assertFalse(redacted.contains("abc123")) + assertFalse(redacted.contains("k-1")) + assertFalse(redacted.contains("shh")) + assertFalse(redacted.contains("swordfish")) + } + + @Test + fun redactsGenericStructuredTokensAndCookies() { + val report = """ + {"token":"short-test-secret"} + {"Cookie":"session=example-secret"} + {"set-cookie":"session=json-cookie"} + {"Authorization":"Basic dXNlcjpwYXNz"} + token=plainvalue + cookie=plaincookie + set_cookie=kv-cookie + """.trimIndent() + + val redacted = Redactor.redact(report) + assertFalse(redacted.contains("short-test-secret")) + assertFalse(redacted.contains("example-secret")) + assertFalse(redacted.contains("json-cookie")) + assertFalse(redacted.contains("dXNlcjpwYXNz")) + assertFalse(redacted.contains("plainvalue")) + assertFalse(redacted.contains("plaincookie")) + assertFalse(redacted.contains("kv-cookie")) + assertTrue(redacted.contains("\"token\"")) + assertTrue(redacted.contains("\"Cookie\"")) + assertTrue(redacted.contains("\"set-cookie\"")) + assertTrue(redacted.contains("\"Authorization\"")) + assertTrue(redacted.contains("set_cookie=")) + } + + @Test + fun redactsLongOpaqueRuns() { + val blob = "A".repeat(64) + val redacted = Redactor.redact("material=$blob setting=ok") + assertFalse(redacted.contains(blob)) + assertTrue(redacted.contains("[redacted]")) + assertTrue(redacted.contains("setting=ok")) + } + + @Test + fun leavesOrdinaryTextAlone() { + val report = """ + java.lang.IllegalStateException: update signature mismatch + at net.spacealtctrl.discordrp.update.UpdateIdentity.verify(UpdateIdentity.kt:31) + App version: 1.5.3 + See https://discord.com/app + """.trimIndent() + + assertEquals(report, Redactor.redact(report)) + } +} diff --git a/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/LoginWebPolicyTest.kt b/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/LoginWebPolicyTest.kt new file mode 100644 index 0000000..7deb742 --- /dev/null +++ b/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/LoginWebPolicyTest.kt @@ -0,0 +1,42 @@ +package net.spacealtctrl.discordrp.ui.screens.login + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LoginWebPolicyTest { + @Test + fun acceptsCanonicalDiscordHttpsOrigins() { + assertTrue(LoginWebPolicy.isDiscordUrl("https://discord.com/login")) + assertTrue(LoginWebPolicy.isDiscordUrl("https://discord.com/app")) + assertTrue(LoginWebPolicy.isDiscordAppUrl("https://discord.com/app")) + assertTrue(LoginWebPolicy.isDiscordAppUrl("https://discord.com/app/")) + assertTrue(LoginWebPolicy.isDiscordAppUrl("https://discord.com/app?from=login")) + } + + @Test + fun onlyAppPathTriggersTokenExtraction() { + assertFalse(LoginWebPolicy.isDiscordAppUrl("https://discord.com/login")) + assertFalse(LoginWebPolicy.isDiscordAppUrl("https://discord.com/app/messages")) + } + + @Test + fun onlyTheLoginOriginsAreTrusted() { + assertFalse(LoginWebPolicy.isDiscordUrl("https://canary.discord.com/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://cdn.discord.com/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://www.discordapp.com/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://discordapp.com/login")) + assertFalse(LoginWebPolicy.isDiscordAppUrl("https://discordapp.com/app")) + assertFalse(LoginWebPolicy.isDiscordAppUrl("https://canary.discord.com/app")) + } + + @Test + fun rejectsLookalikeAndInsecureOrigins() { + assertFalse(LoginWebPolicy.isDiscordUrl("http://discord.com/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://discord.com.evil.test/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://notdiscord.com/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://user@discord.com/app")) + assertFalse(LoginWebPolicy.isDiscordUrl("https://discord.com:8443/app")) + assertFalse(LoginWebPolicy.isDiscordAppUrl("https://evil.test/app")) + } +} diff --git a/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/SessionCleanupTest.kt b/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/SessionCleanupTest.kt new file mode 100644 index 0000000..4c3f66a --- /dev/null +++ b/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/SessionCleanupTest.kt @@ -0,0 +1,156 @@ +package net.spacealtctrl.discordrp.ui.screens.login + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionCleanupTest { + private val ops = FakeOps() + private var timeout: (() -> Unit)? = null + private val cleanup = SessionCleanup(ops) { timeout = it } + + @Test + fun waitsForTheBlankDocumentBeforeWiping() { + var cleaned = 0 + cleanup.start { cleaned++ } + + assertEquals(listOf("stop", "blank"), ops.calls) + assertNull(ops.cookieCallback) + + cleanup.onPageFinished("https://discord.com/app") + assertEquals(listOf("stop", "blank"), ops.calls) + + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + assertEquals(listOf("stop", "blank", "storage", "cookies"), ops.calls) + assertEquals(0, cleaned) + + ops.cookieCallback!!.invoke() + assertEquals(listOf("stop", "blank", "storage", "cookies", "flush", "cache"), ops.calls) + assertEquals(1, cleaned) + } + + @Test + fun releaseDefersDestructionUntilCookiesAreGone() { + cleanup.start() + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + cleanup.release() + + assertEquals(false, ops.calls.contains("destroy")) + + ops.cookieCallback!!.invoke() + assertEquals(listOf("stop", "blank", "storage", "cookies", "flush", "cache", "destroy"), ops.calls) + + cleanup.release() + assertEquals(1, ops.calls.count { it == "destroy" }) + } + + @Test + fun bothPathsShareOneCleanup() { + var cleaned = 0 + cleanup.start { cleaned++ } + cleanup.release() + cleanup.start() + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + + assertEquals(1, ops.calls.count { it == "stop" }) + assertEquals(1, ops.calls.count { it == "storage" }) + assertEquals(1, ops.calls.count { it == "cookies" }) + + ops.cookieCallback!!.invoke() + assertEquals(1, cleaned) + assertEquals(1, ops.calls.count { it == "destroy" }) + } + + @Test + fun timeoutFailsClosedInsteadOfWipingALivePage() { + var cleaned = 0 + cleanup.start { cleaned++ } + timeout!!.invoke() + + assertEquals(listOf("stop", "blank", "detach", "destroy", "storage", "cookies"), ops.calls) + assertTrue(cleanup.isCancelled) + + ops.cookieCallback!!.invoke() + assertEquals( + listOf("stop", "blank", "detach", "destroy", "storage", "cookies", "flush"), + ops.calls, + ) + assertEquals(0, cleaned) + + // A late blank commit or capture result must not restart the normal path. + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + cleanup.start { cleaned++ } + assertEquals( + listOf("stop", "blank", "detach", "destroy", "storage", "cookies", "flush"), + ops.calls, + ) + assertEquals(0, cleaned) + } + + @Test + fun lateCaptureResultAfterCancelIsNotDelivered() { + var cleaned = 0 + cleanup.start { cleaned++ } + cleanup.cancel() + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + + ops.cookieCallback!!.invoke() + assertTrue(cleanup.isCancelled) + + // The pending evaluateJavascript callback returns after cleanup finished. + cleanup.start { cleaned++ } + assertEquals(0, cleaned) + } + + @Test + fun cancellingWithoutACaptureStillCleansUp() { + cleanup.release() + assertEquals(listOf("stop", "blank"), ops.calls) + + cleanup.onPageFinished(SessionCleanup.BLANK_URL) + assertEquals(listOf("stop", "blank", "storage", "cookies"), ops.calls) + + ops.cookieCallback!!.invoke() + assertEquals("destroy", ops.calls.last()) + } + + private class FakeOps : SessionOps { + val calls = mutableListOf() + var cookieCallback: (() -> Unit)? = null + + override fun stopPage() { + calls += "stop" + } + + override fun loadBlank() { + calls += "blank" + } + + override fun detach() { + calls += "detach" + } + + override fun clearStorage() { + calls += "storage" + } + + override fun removeCookies(onDone: () -> Unit) { + calls += "cookies" + cookieCallback = onDone + } + + override fun flushCookies() { + calls += "flush" + } + + override fun clearCache() { + calls += "cache" + } + + override fun destroyPage() { + calls += "destroy" + } + } +} diff --git a/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/TokenShapeTest.kt b/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/TokenShapeTest.kt new file mode 100644 index 0000000..07a45d8 --- /dev/null +++ b/app/src/test/java/net/spacealtctrl/discordrp/ui/screens/login/TokenShapeTest.kt @@ -0,0 +1,25 @@ +package net.spacealtctrl.discordrp.ui.screens.login + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TokenShapeTest { + @Test + fun acceptsDiscordStyleTokens() { + assertTrue(TokenShape.isPlausible("A".repeat(24) + "." + "B".repeat(6) + "." + "C".repeat(38))) + assertTrue(TokenShape.isPlausible("mfa." + "A".repeat(84))) + assertTrue(TokenShape.isPlausible("\"" + "a".repeat(40) + "\"")) + assertTrue(TokenShape.isPlausible(" " + "a".repeat(40) + " ")) + } + + @Test + fun rejectsObviousGarbage() { + assertFalse(TokenShape.isPlausible(null)) + assertFalse(TokenShape.isPlausible("")) + assertFalse(TokenShape.isPlausible("short")) + assertFalse(TokenShape.isPlausible("token with spaces and enough length")) + assertFalse(TokenShape.isPlausible("A".repeat(201))) + assertFalse(TokenShape.isPlausible("line\nbreak")) + } +} diff --git a/app/src/test/java/net/spacealtctrl/discordrp/update/UpdatePolicyTest.kt b/app/src/test/java/net/spacealtctrl/discordrp/update/UpdatePolicyTest.kt new file mode 100644 index 0000000..59fe03f --- /dev/null +++ b/app/src/test/java/net/spacealtctrl/discordrp/update/UpdatePolicyTest.kt @@ -0,0 +1,103 @@ +package net.spacealtctrl.discordrp.update + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UpdatePolicyTest { + private val digest = "a".repeat(64) + + @Test + fun acceptsOnlyUpstreamReleaseAssets() { + assertTrue( + UpdatePolicy.isTrustedApkUrl( + "https://github.com/spacealtctrl/DiscordRP/releases/download/v1.6.0/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedApkUrl( + "https://github.com/cutieway/DiscordRP/releases/download/v1.6.0/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedApkUrl( + "http://github.com/spacealtctrl/DiscordRP/releases/download/v1.6.0/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedApkUrl( + "https://github.com.evil.test/spacealtctrl/DiscordRP/releases/download/v1.6.0/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedApkUrl( + "https://user@github.com/spacealtctrl/DiscordRP/releases/download/v1.6.0/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedApkUrl( + "https://github.com:8443/spacealtctrl/DiscordRP/releases/download/v1.6.0/app.apk", + ), + ) + } + + @Test + fun requiresACompleteSha256Digest() { + assertEquals(digest, UpdatePolicy.sha256("sha256:$digest")) + assertEquals(digest, UpdatePolicy.sha256("sha256:${digest.uppercase()}")) + assertNull(UpdatePolicy.sha256(null)) + assertNull(UpdatePolicy.sha256("")) + assertNull(UpdatePolicy.sha256("sha256:${"a".repeat(63)}")) + assertNull(UpdatePolicy.sha256("sha256:${"a".repeat(65)}")) + assertNull(UpdatePolicy.sha256("md5:$digest")) + } + + @Test + fun acceptsOnlyGitHubRedirectTargets() { + val asset = "https://github.com/spacealtctrl/DiscordRP/releases/download/v1.6.0/app.apk" + assertTrue(UpdatePolicy.isTrustedDownloadUrl(asset, asset)) + assertTrue( + UpdatePolicy.isTrustedDownloadUrl( + asset, + "https://objects.githubusercontent.com/github-production-release-asset-2e65be/1?X-Amz-Signature=abc", + ), + ) + assertTrue( + UpdatePolicy.isTrustedDownloadUrl( + asset, + "https://release-assets.githubusercontent.com/github-production-release-asset/2?X-Amz-Signature=def", + ), + ) + + assertFalse(UpdatePolicy.isTrustedDownloadUrl(asset, "https://evil.test/app.apk")) + assertFalse( + UpdatePolicy.isTrustedDownloadUrl( + asset, + "https://objects.githubusercontent.com.evil.test/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedDownloadUrl( + asset, + "http://objects.githubusercontent.com/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedDownloadUrl( + asset, + "https://user@objects.githubusercontent.com/app.apk", + ), + ) + assertFalse( + UpdatePolicy.isTrustedDownloadUrl( + asset, + "https://objects.githubusercontent.com:8443/app.apk", + ), + ) + assertFalse(UpdatePolicy.isTrustedDownloadUrl(asset, "https://github.com/spacealtctrl")) + assertFalse(UpdatePolicy.isTrustedDownloadUrl("https://evil.test/app.apk", asset)) + assertFalse(UpdatePolicy.isTrustedDownloadUrl(asset, null)) + } +}