Skip to content

Commit

Permalink
feat: store app lock password securely [WPB-4695] (#2249)
Browse files Browse the repository at this point in the history
  • Loading branch information
saleniuk authored Sep 20, 2023
1 parent 5260829 commit 13d5576
Show file tree
Hide file tree
Showing 12 changed files with 437 additions and 49 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Wire
* Copyright (C) 2023 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.datastore

import org.amshove.kluent.internal.assertEquals
import org.amshove.kluent.internal.assertFails
import org.amshove.kluent.internal.assertNotEquals
import org.junit.Test

class EncryptionManagerTest {

@Test
fun givenKeyAlias_whenEncryptingAndDecryptingWithTheSameKeyAlias_thenTheOriginalValueReturned() {
val data = "dataToBeEncrypted123!"
val keyAlias = "key_alias"

val encryptedData = EncryptionManager.encrypt(keyAlias, data)
val decryptedData = EncryptionManager.decrypt(keyAlias, encryptedData)

assertNotEquals(data, encryptedData)
assertEquals(data, decryptedData)
}

@Test
fun givenTwoKeyAliases_whenEncryptingWithOneKeyAliasAndDecryptingWithOtherKeyAlias_thenExceptionThrown() {
val data = "dataToBeEncrypted123!"
val keyAlias1 = "key_alias1"
val keyAlias2 = "key_alias2"

val encryptedData = EncryptionManager.encrypt(keyAlias1, data)
assertFails { EncryptionManager.decrypt(keyAlias2, encryptedData) }
}
}
103 changes: 103 additions & 0 deletions app/src/main/kotlin/com/wire/android/datastore/EncryptionManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Wire
* Copyright (C) 2023 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.datastore

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.io.UnsupportedEncodingException
import java.nio.charset.Charset
import java.security.InvalidKeyException
import java.security.KeyStore
import javax.crypto.AEADBadTagException
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

object EncryptionManager {

private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"

private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private val cipher = Cipher.getInstance(TRANSFORMATION)
private val charset = Charset.defaultCharset()

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

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

@Throws(
UnsupportedOperationException::class,
InvalidKeyException::class,
IllegalStateException::class,
IllegalBlockSizeException::class,
BadPaddingException::class,
AEADBadTagException::class,
UnsupportedEncodingException::class
)
fun encrypt(keyAlias: String, text: String): String {
cipher.init(Cipher.ENCRYPT_MODE, getKey(keyAlias))
val iv = cipher.iv
val encryptedBytes = cipher.doFinal(text.toByteArray())
return listOf(encryptedBytes, iv)
.map { String(Base64.encode(it, Base64.NO_WRAP), charset) }
.joinToString(":")
}

@Throws(
UnsupportedOperationException::class,
InvalidKeyException::class,
IllegalStateException::class,
IllegalBlockSizeException::class,
BadPaddingException::class,
AEADBadTagException::class,
UnsupportedEncodingException::class
)
@Suppress("MagicNumber")
fun decrypt(keyAlias: String, encryptedText: String): String {
val (encryptedData, iv) = encryptedText.split(":")
.map { Base64.decode(it.toByteArray(charset), Base64.NO_WRAP) }
.let { it[0] to it[1] }
cipher.init(Cipher.DECRYPT_MODE, getKey(keyAlias), GCMParameterSpec(128, iv))
val decryptedBytes = cipher.doFinal(encryptedData)
return String(decryptedBytes)
}
}
31 changes: 31 additions & 0 deletions app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.wire.android.BuildConfig
import com.wire.android.migration.failure.UserMigrationStatus
Expand All @@ -50,6 +51,7 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex
private val WELCOME_SCREEN_PRESENTED = booleanPreferencesKey("welcome_screen_presented")
private val IS_LOGGING_ENABLED = booleanPreferencesKey("is_logging_enabled")
private val IS_ENCRYPTED_PROTEUS_STORAGE_ENABLED = booleanPreferencesKey("is_encrypted_proteus_storage_enabled")
private val APP_LOCK_PASSCODE = stringPreferencesKey("app_lock_passcode")
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = PREFERENCES_NAME)
private fun userMigrationStatusKey(userId: String): Preferences.Key<Int> = intPreferencesKey("user_migration_status_$userId")
private fun userDoubleTapToastStatusKey(userId: String): Preferences.Key<Boolean> =
Expand Down Expand Up @@ -133,4 +135,33 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex

suspend fun getShouldShowDoubleTapToast(userId: String): Boolean =
getBooleanPreference(userDoubleTapToastStatusKey(userId), true).first()

@Suppress("TooGenericExceptionCaught")
fun getAppLockPasscodeFlow(): Flow<String?> =
context.dataStore.data.map {
it[APP_LOCK_PASSCODE]?.let {
try {
EncryptionManager.decrypt(APP_LOCK_PASSCODE.name, it)
} catch (e: Exception) {
null
}
}
}

suspend fun clearAppLockPasscode() {
context.dataStore.edit {
it.remove(APP_LOCK_PASSCODE)
}
}

@Suppress("TooGenericExceptionCaught")
suspend fun setAppLockPasscode(passcode: String) {
context.dataStore.edit {
try {
it[APP_LOCK_PASSCODE] = EncryptionManager.encrypt(APP_LOCK_PASSCODE.name, passcode)
} catch (e: Exception) {
it.remove(APP_LOCK_PASSCODE)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Wire
* Copyright (C) 2023 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.feature

import com.wire.android.datastore.GlobalDataStore
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

@ViewModelScoped
class ObserveAppLockConfigUseCase @Inject constructor(
private val globalDataStore: GlobalDataStore,
) {

operator fun invoke(): Flow<AppLockConfig> =
globalDataStore.getAppLockPasscodeFlow().map { // TODO: include checking if any logged account does not enforce app-lock
when {
it.isNullOrEmpty() -> AppLockConfig.Disabled
else -> AppLockConfig.Enabled
}
}
}

sealed class AppLockConfig(open val timeoutInSeconds: Int = DEFAULT_TIMEOUT) {
data object Disabled : AppLockConfig()
data object Enabled : AppLockConfig()
data class EnforcedByTeam(override val timeoutInSeconds: Int) : AppLockConfig(timeoutInSeconds)

companion object {
const val DEFAULT_TIMEOUT = 60
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
Expand All @@ -55,6 +56,8 @@ import com.wire.android.ui.common.textfield.WireTextFieldState
import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar
import com.wire.android.ui.theme.wireColorScheme
import com.wire.android.ui.theme.wireDimensions
import com.wire.android.ui.theme.wireTypography
import java.util.Locale

@RootNavGraph
@Destination
Expand Down Expand Up @@ -93,7 +96,7 @@ fun SetLockCodeScreenContent(
WireCenterAlignedTopAppBar(
onNavigationPressed = onBackPress,
elevation = dimensions().spacing0x,
title = stringResource(id = R.string.settings_privacy_settings_label)
title = stringResource(id = R.string.settings_set_lock_screen_title)
)
}) { internalPadding ->
Column(
Expand All @@ -110,6 +113,17 @@ fun SetLockCodeScreenContent(
testTagsAsResourceId = true
}
) {
Text(
text = stringResource(id = R.string.settings_set_lock_screen_description),
style = MaterialTheme.wireTypography.body01,
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.wireDimensions.spacing16x,
vertical = MaterialTheme.wireDimensions.spacing24x
)
.testTag("registerText")
)
WirePasswordTextField(
value = state.password,
onValueChange = onPasswordChanged,
Expand All @@ -119,7 +133,9 @@ fun SetLockCodeScreenContent(
modifier = Modifier
.testTag("password"),
state = WireTextFieldState.Default,
autofill = false
autofill = false,
placeholderText = stringResource(R.string.settings_set_lock_screen_passcode_label),
labelText = stringResource(R.string.settings_set_lock_screen_passcode_label).uppercase(Locale.getDefault())
)
Spacer(modifier = Modifier.weight(1f))
}
Expand Down Expand Up @@ -152,7 +168,7 @@ private fun ContinueButton(
val interactionSource = remember { MutableInteractionSource() }
Column(modifier = modifier) {
WirePrimaryButton(
text = stringResource(R.string.label_continue),
text = stringResource(R.string.settings_set_lock_screen_continue_button_label),
onClick = onContinue,
state = if (enabled) WireButtonState.Default else WireButtonState.Disabled,
interactionSource = interactionSource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wire.android.datastore.GlobalDataStore
import com.wire.android.util.sha256
import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class SetLockScreenViewModel @Inject constructor(
private val validatePassword: ValidatePasswordUseCase
private val validatePassword: ValidatePasswordUseCase,
private val globalDataStore: GlobalDataStore,
) : ViewModel() {

var state: SetLockCodeViewState by mutableStateOf(SetLockCodeViewState())
Expand All @@ -51,14 +56,16 @@ class SetLockScreenViewModel @Inject constructor(
}

fun onContinue() {
state = state.copy(continueEnabled = false)
// the continue button is enabled iff the password is valid
// this check is for safety only
state = if (!validatePassword(state.password.text)) {
state.copy(isPasswordValid = false)
} else {
// TODO: store password in secure storage
state.copy(done = true)
viewModelScope.launch {
state = state.copy(continueEnabled = false)
// the continue button is enabled iff the password is valid
// this check is for safety only
state = if (!validatePassword(state.password.text)) {
state.copy(isPasswordValid = false)
} else {
globalDataStore.setAppLockPasscode(state.password.text.sha256())
state.copy(done = true)
}
}
}
}
Loading

0 comments on commit 13d5576

Please sign in to comment.