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/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt new file mode 100644 index 0000000..cc714d0 --- /dev/null +++ b/app/src/androidTest/java/net/spacealtctrl/discordrp/settings/TokenStorageTest.kt @@ -0,0 +1,51 @@ +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.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters + +@RunWith(AndroidJUnit4::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class TokenStorageTest { + @Test + fun a_legacyTokenIsMigrated() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prefs = context.getSharedPreferences("stash", Context.MODE_PRIVATE) + prefs.edit() + .putString("discord.token", "legacy-token") + .remove("discord.token.enc") + .commit() + + val stash = Stash.of(context) + assertEquals("legacy-token", stash.authToken) + assertNull(prefs.getString("discord.token", null)) + assertNotNull(prefs.getString("discord.token.enc", null)) + stash.authToken = "" + } + + @Test + fun b_tokenIsSealedAndNeverLeftInPlaintext() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val stash = Stash.of(context) + val previous = stash.authToken + try { + stash.authToken = "smoke-token" + assertEquals("smoke-token", stash.authToken) + + val prefs = context.getSharedPreferences("stash", Context.MODE_PRIVATE) + assertNull(prefs.getString("discord.token", null)) + assertNotNull(prefs.getString("discord.token.enc", null)) + } finally { + stash.authToken = previous + } + assertEquals(previous, stash.authToken) + assertEquals(previous.isNotBlank(), stash.signedIn) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f68969..1dba1e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,9 +9,6 @@ - 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..0482ed8 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt @@ -19,6 +19,9 @@ class Stash private constructor(context: Context) { private val _revisions = MutableStateFlow(0) val revisions: StateFlow = _revisions.asStateFlow() + @Volatile + private var tokenCache: String? = null + private fun write(block: SharedPreferences.Editor.() -> Unit) { val editor = prefs.edit() editor.block() @@ -27,8 +30,41 @@ class Stash private constructor(context: Context) { } var authToken: String - get() = prefs.getString(KEY_TOKEN, null).orEmpty() - set(value) = write { putString(KEY_TOKEN, value) } + get() = tokenCache ?: readToken().also { if (it.isNotEmpty()) tokenCache = it } + set(value) { + val token = value.trim() + if (token.isEmpty()) { + tokenCache = "" + write { + remove(KEY_TOKEN) + remove(KEY_TOKEN_ENC) + } + } else { + val sealed = TokenVault.encrypt(token) + tokenCache = token + write { + putString(KEY_TOKEN_ENC, sealed) + remove(KEY_TOKEN) + } + } + } + + private fun readToken(): String { + prefs.getString(KEY_TOKEN_ENC, null)?.let { sealed -> + TokenVault.decrypt(sealed)?.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) }.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() @@ -206,6 +242,7 @@ class Stash private constructor(context: Context) { } 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..b880ca3 --- /dev/null +++ b/app/src/main/java/net/spacealtctrl/discordrp/settings/TokenVault.kt @@ -0,0 +1,72 @@ +package net.spacealtctrl.discordrp.settings + +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: the gateway service has to read the token while + * the screen is off. + */ +internal object TokenVault { + private const val KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "discordrp.token.v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_BITS = 128 + + @Synchronized + fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key()) + val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + val body = Base64.encodeToString( + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), + Base64.NO_WRAP, + ) + return "$iv:$body" + } + + @Synchronized + fun decrypt(sealed: String): 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(), + GCMParameterSpec(TAG_BITS, Base64.decode(parts[0], Base64.NO_WRAP)), + ) + String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), Charsets.UTF_8) + } + } 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 + } + + private fun key(): SecretKey { + val store = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } +} 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..01ff533 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 @@ -20,25 +20,37 @@ 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.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 @@ -90,7 +102,8 @@ class LoginViewModel @Inject constructor( if (_stage.value == LoginStage.Verifying) 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 } } } @@ -104,6 +117,15 @@ fun LoginScreen( ) { val stage by viewModel.stage.collectAsState() 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 +253,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 +280,76 @@ 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() + viewModel.onToken(cleanToken) + }, + ) { + Text(stringResource(R.string.login_token_manual_submit)) + } + }, + dismissButton = { + TextButton(onClick = ::closeManualToken) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) + } } } 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..9b675f1 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 @@ -1,71 +1,138 @@ package net.spacealtctrl.discordrp.ui.screens.login import android.annotation.SuppressLint +import android.net.Uri import android.os.Build import android.view.View import android.view.ViewGroup +import android.webkit.CookieManager import android.webkit.JsResult import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.runtime.Composable import androidx.compose.ui.viewinterop.AndroidView private const val LOGIN_URL = "https://discord.com/login" +private const val TOKEN_ALERT_PREFIX = "discordrp-token:" 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)()" + "alert('discordrp-token:'%2Bi.contentWindow.localStorage.token.slice(1,-1))%7D)()" 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 isDiscordUrl(raw: String?): Boolean { + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false + if (uri.scheme != "https") return false + val host = uri.host?.lowercase() ?: return false + return host == "discord.com" || + host.endsWith(".discord.com") || + host == "discordapp.com" || + host.endsWith(".discordapp.com") +} + +private fun isDiscordAppUrl(raw: String?): Boolean { + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false + return isDiscordUrl(raw) && uri.path?.trimEnd('/') == "/app" +} + @SuppressLint("SetJavaScriptEnabled") @Composable fun TokenCapture(onToken: (String) -> Unit) { AndroidView( onRelease = { webView -> + // Never leave Discord's web session (and a possible token in its + // localStorage) behind after the login sheet closes. + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) webView.stopLoading() webView.destroy() }, factory = { context -> - WebView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - if (Build.MANUFACTURER.equals("motorola", ignoreCase = true)) { - settings.userAgentString = FALLBACK_UA - } + WebView(context).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.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 (capturing || !isDiscordAppUrl(url)) return + capturing = true + view.loadUrl(TOKEN_HOOK) + view.visibility = View.GONE + } - 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 + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + if (!isDiscordUrl(url)) return true + if (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() + } + 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 + webChromeClient = object : WebChromeClient() { + override fun onJsAlert( + view: WebView, + url: String, + message: String, + result: JsResult, + ): Boolean { + // Only our prefixed alert on Discord's own origin is a + // token; every other page dialog stays with WebView. + if (!message.startsWith(TOKEN_ALERT_PREFIX) || !isDiscordUrl(url)) { + return false + } + val token = message.removePrefix(TOKEN_ALERT_PREFIX).trim() + if (token.isEmpty()) return true + + // The app keeps the token in Keystore-sealed prefs; drop + // Discord's web session copy so it is not stored twice. + WebStorage.getInstance().deleteAllData() + CookieManager.getInstance().removeAllCookies(null) + onToken(token) + view.visibility = View.GONE + return true + } } + loadUrl(LOGIN_URL) } - loadUrl(LOGIN_URL) - } - }) + }, + ) } 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..690adfd 100644 --- a/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt +++ b/app/src/main/java/net/spacealtctrl/discordrp/update/UpdateHub.kt @@ -2,6 +2,7 @@ package net.spacealtctrl.discordrp.update import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.content.FileProvider import dagger.hilt.android.qualifiers.ApplicationContext import io.ktor.client.HttpClient @@ -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 @@ -100,10 +102,23 @@ class UpdateHub @Inject constructor( }.body() val version = release.tagName.removePrefix("v") val apk = release.assets.firstOrNull { it.name.endsWith(".apk") } + ?: return UpdateState.UpToDate + if (!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)) + UpdateState.Available( + AppUpdate( + version = version, + apkUrl = apk.downloadUrl, + sha256 = apk.digest + ?.takeIf { it.startsWith("sha256:") } + ?.removePrefix("sha256:") + ?.lowercase(), + ), + ) else -> UpdateState.UpToDate } } catch (e: CancellationException) { @@ -115,7 +130,9 @@ class UpdateHub @Inject constructor( private suspend fun download(update: AppUpdate): File { val dir = File(context.cacheDir, UPDATES_DIR).apply { mkdirs() } - val file = File(dir, "DiscordRP-${update.version}.apk") + val safeVersion = update.version.replace(Regex("[^A-Za-z0-9._-]"), "_") + val file = File(dir, "DiscordRP-$safeVersion.apk") + val digest = MessageDigest.getInstance("SHA-256") http.prepareGet(update.apkUrl) { timeout { requestTimeoutMillis = DOWNLOAD_TIMEOUT_MS } }.execute { response -> @@ -128,13 +145,31 @@ class UpdateHub @Inject constructor( while (true) { val read = channel.readAvailable(buffer, 0, buffer.size) if (read == -1) break - if (read > 0) out.write(buffer, 0, read) + if (read > 0) { + out.write(buffer, 0, read) + digest.update(buffer, 0, read) + } } } } + val expected = update.sha256 + if (expected != null) { + val actual = digest.digest().joinToString("") { "%02x".format(it) } + if (actual != expected) { + file.delete() + error("download digest mismatch") + } + } return file } + private fun isTrustedApkUrl(raw: String): Boolean { + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return false + return uri.scheme == "https" && + uri.host == "github.com" && + uri.path?.startsWith("/spacealtctrl/DiscordRP/releases/download/") == true + } + private fun launchInstaller(apk: File) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", apk) context.startActivity( @@ -148,6 +183,7 @@ class UpdateHub @Inject constructor( private data class FeedAsset( val name: String, @SerialName("browser_download_url") val downloadUrl: String, + val digest: String? = null, ) @Serializable 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