Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
Binary file not shown.
Binary file not shown.
Binary file added app/src/androidTest/assets/update-unsigned.apk
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
14 changes: 10 additions & 4 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- The player picker enumerates installed apps, so package visibility
needs this broad query. -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission
android:name="android.permission.MEDIA_CONTENT_CONTROL"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Used only to hand a downloaded release APK to the system installer. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
Expand All @@ -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"
Expand Down Expand Up @@ -58,6 +59,8 @@
android:name=".ui.crash.CrashActivity"
android:exported="false"
android:process=":crash" />
<!-- The crash process only renders the already-redacted report; it never
reads the token, so plaintext is not shared across processes. -->

<receiver
android:name=".inbox.InboxDismissReceiver"
Expand All @@ -67,10 +70,13 @@
android:name=".inbox.InboxReplyReceiver"
android:exported="false" />

<!-- BOOT_COMPLETED and MY_PACKAGE_REPLACED are sent by the system, so
exported=false still delivers them without accepting third-party
broadcasts. -->
<receiver
android:name=".service.StartOnBootReceiver"
android:enabled="true"
android:exported="true">
android:exported="false">
<intent-filter android:priority="1000">
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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]+)*")
}
Loading