Skip to content
Closed
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")
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 0 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
<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" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
Expand Down
41 changes: 39 additions & 2 deletions app/src/main/java/net/spacealtctrl/discordrp/settings/Stash.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class Stash private constructor(context: Context) {
private val _revisions = MutableStateFlow(0)
val revisions: StateFlow<Int> = _revisions.asStateFlow()

@Volatile
private var tokenCache: String? = null

private fun write(block: SharedPreferences.Editor.() -> Unit) {
val editor = prefs.edit()
editor.block()
Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -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()
Expand Down Expand Up @@ -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,
)
}
}
}

Expand All @@ -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))
}
},
)
}
}
}
Loading