Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use AndroidKeyStore to encrypt tokens. #125

Merged
merged 2 commits into from
Feb 7, 2025
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.test.manifest)
testImplementation(libs.junit)
Expand Down
104 changes: 104 additions & 0 deletions app/src/androidTest/java/com/OxGames/Pluvia/CryptoTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.OxGames.Pluvia

import androidx.test.ext.junit.runners.AndroidJUnit4
import java.security.SecureRandom
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class CryptoTest {

@Test
fun encryptDecrypt_withString_returnsOriginalString() {
val originalString = "Hello, World!"
val originalBytes = originalString.toByteArray()

val encryptedBytes = Crypto.encrypt(originalBytes)
val decryptedBytes = Crypto.decrypt(encryptedBytes)
val decryptedString = String(decryptedBytes)

assertNotEquals(
"Encrypted bytes should be different from original",
originalBytes.contentToString(),
encryptedBytes.contentToString(),
)
assertEquals("Decrypted string should match original", originalString, decryptedString)
}

@Test
fun encryptDecrypt_withLargeData_succeeds() {
val random = SecureRandom()
val largeData = ByteArray(1024 * 1024) // 1MB
random.nextBytes(largeData)

val encryptedBytes = Crypto.encrypt(largeData)
val decryptedBytes = Crypto.decrypt(encryptedBytes)

assertTrue("Decrypted data should match original", largeData.contentEquals(decryptedBytes))
}

@Test
fun encryptDecrypt_withEmptyData_throwsException() {
val emptyData = ByteArray(0)

try {
Crypto.encrypt(emptyData)
fail("Should have thrown IllegalArgumentException")
} catch (_: IllegalArgumentException) {
}
}

@Test
fun decrypt_withInvalidData_throwsException() {
val invalidData = ByteArray(10)

try {
Crypto.decrypt(invalidData)
fail("Should have thrown IllegalArgumentException")
} catch (_: IllegalArgumentException) {
}
}

@Test
fun encrypt_producesRandomOutput() {
val input = "Test".toByteArray()

val firstEncryption = Crypto.encrypt(input)
val secondEncryption = Crypto.encrypt(input)

assertNotEquals(
"Multiple encryptions of same data should produce different results",
firstEncryption.contentToString(),
secondEncryption.contentToString(),
)
}

@Test
fun encryptDecrypt_withSpecialCharacters_succeeds() {
val specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`"
val originalBytes = specialChars.toByteArray()

val encryptedBytes = Crypto.encrypt(originalBytes)
val decryptedBytes = Crypto.decrypt(encryptedBytes)

assertTrue(
"Decrypted special characters should match original",
originalBytes.contentEquals(decryptedBytes),
)
}

@Test
fun encryptDecrypt_withMultipleOperations_succeeds() {
val testData = List(10) { "Test data $it".toByteArray() }

testData.forEach { originalBytes ->
val encryptedBytes = Crypto.encrypt(originalBytes)
val decryptedBytes = Crypto.decrypt(encryptedBytes)
assertTrue(
"Each operation should succeed",
originalBytes.contentEquals(decryptedBytes),
)
}
}
}
76 changes: 76 additions & 0 deletions app/src/main/java/com/OxGames/Pluvia/Crypto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.OxGames.Pluvia

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec

/**
* Crypto class that uses the Android KeyStore
* Reference: https://github.com/philipplackner/EncryptedDataStore
*/
object Crypto {

private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private const val KEY_ALIAS = "pluvia_secret"
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"

private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

// Thread 'Safety'
private fun getCipher(): Cipher = Cipher.getInstance(TRANSFORMATION)

private fun getKey(): SecretKey {
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
}

private fun createKey(): SecretKey {
return KeyGenerator
.getInstance(ALGORITHM)
.apply {
val keySpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
init(
keySpec.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(false)
.setKeySize(256)
.build(),
)
}
.generateKey()
}

fun encrypt(bytes: ByteArray): ByteArray {
require(bytes.isNotEmpty()) {
"Input bytes cannot be empty"
}

val cipher = getCipher()
cipher.init(Cipher.ENCRYPT_MODE, getKey())
return cipher.iv + cipher.doFinal(bytes)
}

fun decrypt(bytes: ByteArray): ByteArray {
val cipher = getCipher()

require(bytes.size > cipher.blockSize) {
"Input bytes too short to contain IV and data. " +
"Minimum length is ${cipher.blockSize + 1}"
}

val iv = bytes.copyOfRange(0, cipher.blockSize)
val data = bytes.copyOfRange(cipher.blockSize, bytes.size)
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
return cipher.doFinal(data)
}
}
80 changes: 53 additions & 27 deletions app/src/main/java/com/OxGames/Pluvia/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.byteArrayPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.intPreferencesKey
Expand All @@ -21,9 +22,9 @@ import com.winlator.container.Container
import com.winlator.core.DefaultVersion
import `in`.dragonbra.javasteam.enums.EPersonaState
import java.util.EnumSet
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
Expand All @@ -44,12 +45,33 @@ object PrefManager {
},
)

private val scope = CoroutineScope(Dispatchers.IO + CoroutineName("PrefManager"))
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

private lateinit var dataStore: DataStore<Preferences>

fun init(context: Context) {
dataStore = context.datastore

// Note: Should remove after a few release versions. we've moved to encrypted values.
val oldPassword = stringPreferencesKey("password")
removePref(oldPassword)

val oldAccessToken = stringPreferencesKey("access_token")
val oldRefreshToken = stringPreferencesKey("refresh_token")
getPref(oldAccessToken, "").let {
if (it.isNotEmpty()) {
Timber.i("Converting old access token to encrypted")
accessToken = it
removePref(oldAccessToken)
}
}
getPref(oldRefreshToken, "").let {
if (it.isNotEmpty()) {
Timber.i("Converting old refresh token to encrypted")
refreshToken = it
removePref(oldRefreshToken)
}
}
}

fun clearPreferences() {
Expand All @@ -76,11 +98,11 @@ object PrefManager {
}
}

// private fun <T> removePref(key: Preferences.Key<T>) {
// scope.launch {
// dataStore.edit { pref -> pref.remove(key) }
// }
// }
private fun <T> removePref(key: Preferences.Key<T>) {
scope.launch {
dataStore.edit { pref -> pref.remove(key) }
}
}

/* Container Default Settings */
private val SCREEN_SIZE = stringPreferencesKey("screen_size")
Expand Down Expand Up @@ -281,18 +303,36 @@ object PrefManager {
setPref(APP_STAGING_PATH, value)
}

private val ACCESS_TOKEN = stringPreferencesKey("access_token")
private val ACCESS_TOKEN_ENC = byteArrayPreferencesKey("access_token_enc")
var accessToken: String
get() = getPref(ACCESS_TOKEN, "")
get() {
val encryptedBytes = getPref(ACCESS_TOKEN_ENC, ByteArray(0))
return if (encryptedBytes.isEmpty()) {
""
} else {
val bytes = Crypto.decrypt(encryptedBytes)
String(bytes)
}
}
set(value) {
setPref(ACCESS_TOKEN, value)
val bytes = Crypto.encrypt(value.toByteArray())
setPref(ACCESS_TOKEN_ENC, bytes)
}

private val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val REFRESH_TOKEN_ENC = byteArrayPreferencesKey("refresh_token_enc")
var refreshToken: String
get() = getPref(REFRESH_TOKEN, "")
get() {
val encryptedBytes = getPref(REFRESH_TOKEN_ENC, ByteArray(0))
return if (encryptedBytes.isEmpty()) {
""
} else {
val bytes = Crypto.decrypt(encryptedBytes)
String(bytes)
}
}
set(value) {
setPref(REFRESH_TOKEN, value)
val bytes = Crypto.encrypt(value.toByteArray())
setPref(REFRESH_TOKEN_ENC, bytes)
}

// Special: Because null value.
Expand All @@ -305,20 +345,6 @@ object PrefManager {
}
}

private val REMEMBER_PASSWORD = booleanPreferencesKey("remember_password")
var rememberPassword: Boolean
get() = getPref(REMEMBER_PASSWORD, false)
set(value) {
setPref(REMEMBER_PASSWORD, value)
}

private val PASSWORD = stringPreferencesKey("password")
var password: String
get() = getPref(PASSWORD, "")
set(value) {
setPref(PASSWORD, value)
}

/**
* Get or Set the last known Persona State. See [EPersonaState]
*/
Expand Down
Loading