From f6f86201022adae6f2665a47a2974918a8a8c1c3 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 22 Jan 2026 14:26:30 +0500 Subject: [PATCH] feat: Implement BiometricAuthManager for biometric authentication and password encryption/decryption --- app/src/main/AndroidManifest.xml | 1 + .../com/rosetta/messenger/MainActivity.kt | 3 +- .../biometric/BiometricAuthManager.kt | 285 +++++++++++ .../biometric/BiometricPreferences.kt | 95 ++++ .../rosetta/messenger/ui/auth/UnlockScreen.kt | 470 +++++++++++++----- .../chats/components/ChatDetailComponents.kt | 97 ++-- docs/BIOMETRIC_AUTHENTICATION.md | 206 ++++++++ docs/BIOMETRIC_USER_GUIDE_RU.md | 79 +++ 8 files changed, 1094 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt create mode 100644 app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt create mode 100644 docs/BIOMETRIC_AUTHENTICATION.md create mode 100644 docs/BIOMETRIC_USER_GUIDE_RU.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f50626c..fdcf930 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + BiometricAvailability.Available + + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> + BiometricAvailability.NotAvailable("Биометрическое оборудование недоступно") + + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> + BiometricAvailability.NotAvailable("Биометрическое оборудование временно недоступно") + + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + BiometricAvailability.NotEnrolled("Биометрические данные не настроены") + + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> + BiometricAvailability.NotAvailable("Требуется обновление безопасности") + + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> + BiometricAvailability.NotAvailable("Биометрическая аутентификация не поддерживается") + + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> + BiometricAvailability.NotAvailable("Неизвестный статус") + + else -> BiometricAvailability.NotAvailable("Неизвестная ошибка") + } + } + + /** + * Генерирует или получает ключ из Android Keystore + */ + private fun getSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + + // Проверяем существование ключа + if (!keyStore.containsAlias(KEY_NAME)) { + generateSecretKey() + } + + return keyStore.getKey(KEY_NAME, null) as SecretKey + } + + /** + * Генерирует новый ключ в Android Keystore + */ + private fun generateSecretKey() { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + val purposes = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + + val builder = KeyGenParameterSpec.Builder(KEY_NAME, purposes) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + + // Для Android 11+ можем указать тайм-аут аутентификации + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setUserAuthenticationParameters( + 0, // 0 означает, что требуется аутентификация для каждого использования + KeyProperties.AUTH_BIOMETRIC_STRONG + ) + } + + keyGenerator.init(builder.build()) + keyGenerator.generateKey() + } + + /** + * Шифрует пароль с помощью биометрического ключа + */ + fun encryptPassword( + activity: FragmentActivity, + password: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + onCancel: () -> Unit + ) { + try { + val cipher = getCipher() + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + + val biometricPrompt = createBiometricPrompt( + activity, + onSuccess = { cryptoObject -> + try { + val encryptedPassword = cryptoObject.cipher?.doFinal(password.toByteArray()) + val iv = cryptoObject.cipher?.iv + + if (encryptedPassword != null && iv != null) { + // Сохраняем IV вместе с зашифрованными данными + val ivString = Base64.encodeToString(iv, Base64.NO_WRAP) + val encryptedString = Base64.encodeToString(encryptedPassword, Base64.NO_WRAP) + onSuccess("$ivString$IV_SEPARATOR$encryptedString") + } else { + onError("Ошибка шифрования") + } + } catch (e: Exception) { + onError("Ошибка при шифровании: ${e.message}") + } + }, + onError = onError, + onCancel = onCancel + ) + + val promptInfo = createPromptInfo("Сохранить пароль") + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + + } catch (e: Exception) { + onError("Ошибка инициализации: ${e.message}") + } + } + + /** + * Расшифровывает пароль с помощью биометрического ключа + */ + fun decryptPassword( + activity: FragmentActivity, + encryptedData: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit, + onCancel: () -> Unit + ) { + try { + // Разделяем IV и зашифрованные данные + val parts = encryptedData.split(IV_SEPARATOR) + if (parts.size != 2) { + onError("Неверный формат данных") + return + } + + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val encryptedPassword = Base64.decode(parts[1], Base64.NO_WRAP) + + val cipher = getCipher() + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + val biometricPrompt = createBiometricPrompt( + activity, + onSuccess = { cryptoObject -> + try { + val decryptedPassword = cryptoObject.cipher?.doFinal(encryptedPassword) + if (decryptedPassword != null) { + onSuccess(String(decryptedPassword)) + } else { + onError("Ошибка расшифровки") + } + } catch (e: Exception) { + onError("Ошибка при расшифровке: ${e.message}") + } + }, + onError = onError, + onCancel = onCancel + ) + + val promptInfo = createPromptInfo("Разблокировать приложение") + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + + } catch (e: Exception) { + onError("Ошибка инициализации: ${e.message}") + } + } + + /** + * Создает BiometricPrompt с обработчиками + */ + private fun createBiometricPrompt( + activity: FragmentActivity, + onSuccess: (BiometricPrompt.CryptoObject) -> Unit, + onError: (String) -> Unit, + onCancel: () -> Unit + ): BiometricPrompt { + val executor = ContextCompat.getMainExecutor(context) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + if (errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) { + onCancel() + } else { + onError(errString.toString()) + } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + result.cryptoObject?.let { onSuccess(it) } + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + // Не вызываем onError здесь, так как пользователь может попробовать снова + } + } + + return BiometricPrompt(activity, executor, callback) + } + + /** + * Создает информацию для диалога биометрической аутентификации + */ + private fun createPromptInfo(title: String): BiometricPrompt.PromptInfo { + return BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle("Используйте биометрию для аутентификации") + .setNegativeButtonText("Использовать пароль") + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK + ) + .build() + } + + /** + * Получает экземпляр Cipher для шифрования/расшифровки + */ + private fun getCipher(): Cipher { + return Cipher.getInstance(TRANSFORMATION) + } + + /** + * Удаляет сохраненные биометрические данные + */ + fun removeBiometricData() { + try { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + keyStore.deleteEntry(KEY_NAME) + } catch (e: Exception) { + // Игнорируем ошибки при удалении + } + } +} + +/** + * Результат проверки доступности биометрии + */ +sealed class BiometricAvailability { + object Available : BiometricAvailability() + data class NotAvailable(val reason: String) : BiometricAvailability() + data class NotEnrolled(val reason: String) : BiometricAvailability() +} diff --git a/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt new file mode 100644 index 0000000..ae716f8 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt @@ -0,0 +1,95 @@ +package com.rosetta.messenger.biometric + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private val Context.biometricDataStore: DataStore by preferencesDataStore(name = "biometric_prefs") + +/** + * Управление настройками и данными биометрической аутентификации + */ +class BiometricPreferences(private val context: Context) { + + companion object { + private val BIOMETRIC_ENABLED = booleanPreferencesKey("biometric_enabled") + private val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_" + } + + /** + * Включена ли биометрическая аутентификация + */ + val isBiometricEnabled: Flow = context.biometricDataStore.data + .map { preferences -> + preferences[BIOMETRIC_ENABLED] ?: false + } + + /** + * Включить биометрическую аутентификацию + */ + suspend fun enableBiometric() { + context.biometricDataStore.edit { preferences -> + preferences[BIOMETRIC_ENABLED] = true + } + } + + /** + * Отключить биометрическую аутентификацию + */ + suspend fun disableBiometric() { + context.biometricDataStore.edit { preferences -> + preferences[BIOMETRIC_ENABLED] = false + } + } + + /** + * Сохранить зашифрованный пароль для аккаунта + */ + suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) { + val key = stringPreferencesKey("$ENCRYPTED_PASSWORD_PREFIX$publicKey") + context.biometricDataStore.edit { preferences -> + preferences[key] = encryptedPassword + } + } + + /** + * Получить зашифрованный пароль для аккаунта + */ + suspend fun getEncryptedPassword(publicKey: String): String? { + val key = stringPreferencesKey("$ENCRYPTED_PASSWORD_PREFIX$publicKey") + return context.biometricDataStore.data.first()[key] + } + + /** + * Удалить зашифрованный пароль для аккаунта + */ + suspend fun removeEncryptedPassword(publicKey: String) { + val key = stringPreferencesKey("$ENCRYPTED_PASSWORD_PREFIX$publicKey") + context.biometricDataStore.edit { preferences -> + preferences.remove(key) + } + } + + /** + * Удалить все биометрические данные + */ + suspend fun clearAll() { + context.biometricDataStore.edit { preferences -> + preferences.clear() + } + } + + /** + * Проверить, есть ли сохраненный зашифрованный пароль для аккаунта + */ + suspend fun hasEncryptedPassword(publicKey: String): Boolean { + return getEncryptedPassword(publicKey) != null + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 4e1aff8..a761213 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -32,7 +32,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity import com.rosetta.messenger.R +import com.rosetta.messenger.biometric.BiometricAuthManager +import com.rosetta.messenger.biometric.BiometricAvailability +import com.rosetta.messenger.biometric.BiometricPreferences import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount @@ -43,7 +47,9 @@ import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarText import com.rosetta.messenger.ui.chats.utils.getInitials import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first // Account model for dropdown data class AccountItem( @@ -52,6 +58,87 @@ data class AccountItem( val encryptedAccount: EncryptedAccount ) +/** + * Функция для выполнения разблокировки аккаунта + */ +private suspend fun performUnlock( + selectedAccount: AccountItem?, + password: String, + accountManager: AccountManager, + onUnlocking: (Boolean) -> Unit, + onError: (String) -> Unit, + onSuccess: (DecryptedAccount) -> Unit +) { + if (selectedAccount == null) { + onError("Please select an account") + return + } + + onUnlocking(true) + + try { + val account = selectedAccount.encryptedAccount + + // Try to decrypt + val decryptedPrivateKey = CryptoManager.decryptWithPassword( + account.encryptedPrivateKey, + password + ) + + if (decryptedPrivateKey == null) { + onError("Incorrect password") + onUnlocking(false) + return + } + + val decryptedSeedPhrase = CryptoManager.decryptWithPassword( + account.encryptedSeedPhrase, + password + )?.split(" ") ?: emptyList() + + val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey) + + val decryptedAccount = DecryptedAccount( + publicKey = account.publicKey, + privateKey = decryptedPrivateKey, + seedPhrase = decryptedSeedPhrase, + privateKeyHash = privateKeyHash, + name = account.name + ) + + android.util.Log.d("UnlockScreen", "🔐 Starting connection and authentication...") + + // Connect to server and authenticate + ProtocolManager.connect() + + // Wait for websocket connection + var waitAttempts = 0 + while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) { + kotlinx.coroutines.delay(100) + waitAttempts++ + } + + if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) { + onError("Failed to connect to server") + onUnlocking(false) + return + } + + kotlinx.coroutines.delay(300) + + android.util.Log.d("UnlockScreen", "🔐 Starting authentication...") + ProtocolManager.authenticate(account.publicKey, privateKeyHash) + + accountManager.setCurrentAccount(account.publicKey) + + android.util.Log.d("UnlockScreen", "✅ Unlock complete") + onSuccess(decryptedAccount) + } catch (e: Exception) { + onError("Failed to unlock: ${e.message}") + onUnlocking(false) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun UnlockScreen( @@ -84,7 +171,10 @@ fun UnlockScreen( ) val context = LocalContext.current + val activity = context as? FragmentActivity val accountManager = remember { AccountManager(context) } + val biometricManager = remember { BiometricAuthManager(context) } + val biometricPrefs = remember { BiometricPreferences(context) } val scope = rememberCoroutineScope() var password by remember { mutableStateOf("") } @@ -92,6 +182,10 @@ fun UnlockScreen( var isUnlocking by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + // Биометрия + var biometricAvailable by remember { mutableStateOf(BiometricAvailability.NotAvailable("Проверка...")) } + var isBiometricEnabled by remember { mutableStateOf(false) } + // Account selection state var accounts by remember { mutableStateOf>(emptyList()) } var selectedAccount by remember { mutableStateOf(null) } @@ -125,6 +219,56 @@ fun UnlockScreen( } selectedAccount = targetAccount ?: accounts.firstOrNull() + + // Проверяем доступность биометрии + biometricAvailable = biometricManager.isBiometricAvailable() + isBiometricEnabled = biometricPrefs.isBiometricEnabled.first() + + android.util.Log.d("UnlockScreen", "🔐 Biometric available: $biometricAvailable") + android.util.Log.d("UnlockScreen", "🔐 Biometric enabled: $isBiometricEnabled") + android.util.Log.d("UnlockScreen", "🔐 Activity: $activity") + } + + // Автоматически пытаемся разблокировать через биометрию при выборе аккаунта + LaunchedEffect(selectedAccount, isBiometricEnabled) { + if (selectedAccount != null && isBiometricEnabled && activity != null && + biometricAvailable is BiometricAvailability.Available) { + + val encryptedPassword = biometricPrefs.getEncryptedPassword(selectedAccount!!.publicKey) + if (encryptedPassword != null) { + // Небольшая задержка для анимации UI + delay(500) + + // Запускаем биометрическую аутентификацию + biometricManager.decryptPassword( + activity = activity, + encryptedData = encryptedPassword, + onSuccess = { decryptedPassword -> + password = decryptedPassword + // Автоматически разблокируем + scope.launch { + performUnlock( + selectedAccount = selectedAccount, + password = decryptedPassword, + accountManager = accountManager, + onUnlocking = { isUnlocking = it }, + onError = { error = it }, + onSuccess = { decryptedAccount -> + onUnlocked(decryptedAccount) + } + ) + } + }, + onError = { errorMessage -> + // Если биометрия не сработала, пользователь может ввести пароль вручную + android.util.Log.e("UnlockScreen", "Biometric error: $errorMessage") + }, + onCancel = { + // Пользователь отменил биометрию, покажем поле для ввода пароля + } + ) + } + } } // Filter accounts by search @@ -571,126 +715,226 @@ fun UnlockScreen( visible = visible && !isDropdownExpanded, enter = fadeIn(tween(400, delayMillis = 500)) ) { - Button( - onClick = { - if (selectedAccount == null) { - error = "Please select an account" - return@Button - } - if (password.isEmpty()) { - error = "Please enter your password" - return@Button - } - - isUnlocking = true - scope.launch { - try { - val account = selectedAccount!!.encryptedAccount - - // Try to decrypt - val decryptedPrivateKey = - CryptoManager.decryptWithPassword( - account.encryptedPrivateKey, - password - ) - - if (decryptedPrivateKey == null) { - error = "Incorrect password" - isUnlocking = false - return@launch - } - - val decryptedSeedPhrase = - CryptoManager.decryptWithPassword( - account.encryptedSeedPhrase, - password - ) - ?.split(" ") - ?: emptyList() - - val privateKeyHash = - CryptoManager.generatePrivateKeyHash( - decryptedPrivateKey - ) - - val decryptedAccount = - DecryptedAccount( - publicKey = account.publicKey, - privateKey = decryptedPrivateKey, - seedPhrase = decryptedSeedPhrase, - privateKeyHash = privateKeyHash, - name = account.name - ) - - android.util.Log.d("UnlockScreen", "🔐 Starting connection and authentication...") - android.util.Log.d("UnlockScreen", " PublicKey: ${account.publicKey.take(16)}...") - android.util.Log.d("UnlockScreen", " PrivateKeyHash: ${privateKeyHash.take(16)}...") - - // Connect to server and authenticate - ProtocolManager.connect() - - // 🔥 Ждем пока websocket подключится (CONNECTED state) - var waitAttempts = 0 - while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) { - kotlinx.coroutines.delay(100) - waitAttempts++ - } - - android.util.Log.d("UnlockScreen", "🔌 Connection state after wait: ${ProtocolManager.state.value}") - - if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) { - error = "Failed to connect to server" - isUnlocking = false - return@launch - } - - // Еще немного ждем для стабильности - kotlinx.coroutines.delay(300) - - android.util.Log.d("UnlockScreen", "🔐 Starting authentication...") - ProtocolManager.authenticate(account.publicKey, privateKeyHash) - - accountManager.setCurrentAccount(account.publicKey) - - android.util.Log.d("UnlockScreen", "✅ Unlock complete, calling onUnlocked callback") - onUnlocked(decryptedAccount) - } catch (e: Exception) { - error = "Failed to unlock: \${e.message}" - isUnlocking = false + Column { + Button( + onClick = { + if (selectedAccount == null) { + error = "Please select an account" + return@Button } - } - }, - enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking, - modifier = Modifier.fillMaxWidth().height(56.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = PrimaryBlue, - contentColor = Color.White, - disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), - disabledContentColor = Color.White.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(12.dp) - ) { - if (isUnlocking) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Icon( - imageVector = Icons.Default.LockOpen, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + if (password.isEmpty()) { + error = "Please enter your password" + return@Button + } + + scope.launch { + performUnlock( + selectedAccount = selectedAccount, + password = password, + accountManager = accountManager, + onUnlocking = { isUnlocking = it }, + onError = { error = it }, + onSuccess = { decryptedAccount -> + // Если биометрия доступна и включена, сохраняем пароль + if (biometricAvailable is BiometricAvailability.Available && + isBiometricEnabled && activity != null) { + scope.launch { + biometricManager.encryptPassword( + activity = activity, + password = password, + onSuccess = { encryptedPassword -> + scope.launch { + biometricPrefs.saveEncryptedPassword( + decryptedAccount.publicKey, + encryptedPassword + ) + } + }, + onError = { /* Игнорируем ошибки при сохранении */ }, + onCancel = { /* Пользователь отменил */ } + ) + } + } + onUnlocked(decryptedAccount) + } + ) + } + }, + enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White, + disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), + disabledContentColor = Color.White.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(12.dp) + ) { + if (isUnlocking) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } } } } Spacer(modifier = Modifier.height(16.dp)) + // Кнопка для включения/отключения биометрии (вне AnimatedVisibility) + if (biometricAvailable is BiometricAvailability.Available && activity != null && !isDropdownExpanded) { + // Кнопка входа по биометрии (если биометрия включена и пароль сохранен) + var hasSavedPassword by remember { mutableStateOf(false) } + + LaunchedEffect(selectedAccount, isBiometricEnabled) { + hasSavedPassword = if (selectedAccount != null && isBiometricEnabled) { + biometricPrefs.hasEncryptedPassword(selectedAccount!!.publicKey) + } else false + } + + if (isBiometricEnabled && hasSavedPassword) { + // Кнопка для входа по биометрии + OutlinedButton( + onClick = { + selectedAccount?.let { account -> + scope.launch { + val encryptedPassword = biometricPrefs.getEncryptedPassword(account.publicKey) + if (encryptedPassword != null) { + biometricManager.decryptPassword( + activity = activity, + encryptedData = encryptedPassword, + onSuccess = { decryptedPassword -> + password = decryptedPassword + scope.launch { + performUnlock( + selectedAccount = selectedAccount, + password = decryptedPassword, + accountManager = accountManager, + onUnlocking = { isUnlocking = it }, + onError = { error = it }, + onSuccess = { decryptedAccount -> + onUnlocked(decryptedAccount) + } + ) + } + }, + onError = { errorMessage -> + error = "Ошибка биометрии: $errorMessage" + }, + onCancel = { } + ) + } + } + } + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, PrimaryBlue) + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Войти по отпечатку", + color = PrimaryBlue, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + } + + // Переключатель биометрии + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + scope.launch { + if (isBiometricEnabled) { + // Отключаем биометрию + biometricPrefs.disableBiometric() + selectedAccount?.let { + biometricPrefs.removeEncryptedPassword(it.publicKey) + } + isBiometricEnabled = false + } else { + // Включаем биометрию - нужно сохранить пароль + if (password.isNotEmpty()) { + // Сначала проверим пароль + val account = selectedAccount?.encryptedAccount + if (account != null) { + val decryptedPrivateKey = CryptoManager.decryptWithPassword( + account.encryptedPrivateKey, + password + ) + if (decryptedPrivateKey != null) { + // Пароль верный, сохраняем через биометрию + biometricManager.encryptPassword( + activity = activity, + password = password, + onSuccess = { encryptedPassword -> + scope.launch { + biometricPrefs.enableBiometric() + biometricPrefs.saveEncryptedPassword( + account.publicKey, + encryptedPassword + ) + isBiometricEnabled = true + } + }, + onError = { errorMsg -> + error = "Ошибка сохранения: $errorMsg" + }, + onCancel = { } + ) + } else { + error = "Неверный пароль" + } + } + } else { + error = "Сначала введите пароль" + } + } + } + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + tint = if (isBiometricEnabled) PrimaryBlue else secondaryTextColor, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isBiometricEnabled) "Биометрия включена" else "Включить биометрию", + color = if (isBiometricEnabled) PrimaryBlue else secondaryTextColor, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + // Create New Account button AnimatedVisibility( visible = visible && !isDropdownExpanded, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 8af04a4..b8e6c01 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -335,38 +335,79 @@ fun MessageBubble( Spacer(modifier = Modifier.height(4.dp)) } - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.wrapContentWidth() - ) { - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - modifier = Modifier.wrapContentWidth() - ) - + // Если есть reply - текст слева, время справа на одной строке + if (message.replyData != null) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 2.dp) + verticalAlignment = Alignment.Bottom, + modifier = Modifier.fillMaxWidth() ) { - Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.weight(1f, fill = false) ) - if (message.isOutgoing) { - val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status - AnimatedMessageStatus( - status = displayStatus, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + + Spacer(modifier = Modifier.width(10.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) + ) { + Text( + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic ) + if (message.isOutgoing) { + val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status + AnimatedMessageStatus( + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete + ) + } + } + } + } else { + // Без reply - компактно в одну строку + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.wrapContentWidth() + ) { + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.wrapContentWidth() + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) + ) { + Text( + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + ) + if (message.isOutgoing) { + val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status + AnimatedMessageStatus( + status = displayStatus, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete + ) + } } } } diff --git a/docs/BIOMETRIC_AUTHENTICATION.md b/docs/BIOMETRIC_AUTHENTICATION.md new file mode 100644 index 0000000..e850aec --- /dev/null +++ b/docs/BIOMETRIC_AUTHENTICATION.md @@ -0,0 +1,206 @@ +# Биометрическая аутентификация в Rosetta Messenger + +## Обзор + +В Android-приложение добавлена поддержка биометрической аутентификации (отпечаток пальца / Face ID), что позволяет пользователям разблокировать приложение без ввода пароля каждый раз. + +## Особенности реализации + +### 1. Безопасность + +- **Android Keystore**: Пароль шифруется с использованием ключа, хранящегося в Android Keystore +- **Биометрическая защита**: Ключ требует биометрической аутентификации для расшифровки +- **GCM шифрование**: Используется AES-GCM для шифрования пароля +- **Автоматическая инвалидация**: При изменении биометрических данных ключ становится недействительным + +### 2. Компоненты + +#### BiometricAuthManager + +Основной менеджер для работы с биометрией: + +- `isBiometricAvailable()` - проверка доступности биометрии на устройстве +- `encryptPassword()` - шифрование пароля с биометрической защитой +- `decryptPassword()` - расшифровка пароля через биометрию +- `removeBiometricData()` - удаление сохраненных данных + +#### BiometricPreferences + +Управление настройками биометрии: + +- Сохранение зашифрованных паролей для каждого аккаунта +- Включение/отключение биометрии +- Проверка наличия сохраненных данных + +#### Интеграция в UnlockScreen + +- Автоматическая попытка разблокировки при открытии экрана +- Кнопка для включения/отключения биометрии +- Автоматическое сохранение пароля после успешного входа + +### 3. Пользовательский опыт + +#### Первое использование + +1. Пользователь вводит пароль для разблокировки +2. Если биометрия доступна, появляется опция "Включить биометрию" +3. При включении пароль сохраняется зашифрованным +4. При следующем входе автоматически появляется диалог биометрии + +#### Автоматическая разблокировка + +- При открытии экрана разблокировки автоматически запускается биометрическая аутентификация +- Пользователь может отменить и ввести пароль вручную +- При неудаче биометрии доступен ввод пароля + +#### Управление + +- Переключатель "Биометрия включена / Включить биометрию" под кнопкой Unlock +- При отключении зашифрованный пароль удаляется +- Можно включить/отключить в любой момент + +### 4. Поддерживаемые методы + +- **Отпечаток пальца** (Fingerprint) +- **Face ID** (на устройствах с поддержкой) +- **Iris** (на поддерживаемых устройствах) +- Любые другие биометрические методы, поддерживаемые Android BiometricPrompt + +### 5. Требования + +- Android API 24+ (Android 7.0) +- Устройство с биометрическим оборудованием +- Настроенные биометрические данные в системе +- Разрешение `USE_BIOMETRIC` (добавлено в AndroidManifest.xml) + +## Технические детали + +### Зависимости + +```kotlin +implementation("androidx.biometric:biometric:1.1.0") +``` + +### Разрешения + +```xml + +``` + +### Хранение данных + +- Зашифрованные пароли хранятся в DataStore +- Отдельный пароль для каждого аккаунта +- Формат: `encrypted_password_{publicKey}` + +### Шифрование + +- **Алгоритм**: AES/GCM/NoPadding +- **Размер ключа**: 256 бит +- **Хранилище**: Android Keystore +- **IV**: Генерируется для каждого шифрования + +## Использование + +### Для разработчиков + +#### Проверка доступности биометрии + +```kotlin +val biometricManager = BiometricAuthManager(context) +when (biometricManager.isBiometricAvailable()) { + BiometricAvailability.Available -> { + // Биометрия доступна + } + is BiometricAvailability.NotAvailable -> { + // Недоступна (причина в reason) + } + is BiometricAvailability.NotEnrolled -> { + // Биометрия не настроена + } +} +``` + +#### Шифрование пароля + +```kotlin +biometricManager.encryptPassword( + activity = activity, + password = userPassword, + onSuccess = { encryptedPassword -> + // Сохранить зашифрованный пароль + }, + onError = { errorMessage -> + // Обработка ошибки + }, + onCancel = { + // Пользователь отменил + } +) +``` + +#### Расшифровка пароля + +```kotlin +biometricManager.decryptPassword( + activity = activity, + encryptedData = savedEncryptedPassword, + onSuccess = { decryptedPassword -> + // Использовать пароль для входа + }, + onError = { errorMessage -> + // Показать ошибку + }, + onCancel = { + // Показать ввод пароля + } +) +``` + +## Безопасность + +### Что защищено + +✅ Пароль никогда не хранится в открытом виде +✅ Ключ шифрования защищен биометрией +✅ Ключ недействителен после смены биометрических данных +✅ Требуется подтверждение для каждой расшифровки + +### Что НЕ защищено + +❌ Root-доступ может скомпрометировать Keystore +❌ Вредоносное ПО с правами администратора + +## Тестирование + +### Эмулятор + +1. Settings → Security → Fingerprint +2. Добавить отпечаток +3. В терминале эмулятора: `adb -e emu finger touch 1` + +### Реальное устройство + +1. Настроить биометрию в системных настройках +2. Запустить приложение +3. Войти с паролем +4. Включить биометрию +5. Перезапустить приложение + +## Известные ограничения + +1. **Множественные аккаунты**: Биометрия настраивается отдельно для каждого аккаунта +2. **Смена биометрии**: При изменении отпечатков/Face ID нужно заново включить биометрию +3. **Фоновая работа**: Биометрия работает только когда приложение активно + +## Будущие улучшения + +- [ ] Настройка в профиле пользователя +- [ ] Опция "Всегда спрашивать биометрию" +- [ ] Статистика использования биометрии +- [ ] Поддержка PIN-кода как альтернативы +- [ ] Экспорт/импорт настроек биометрии + +## Заключение + +Биометрическая аутентификация значительно улучшает пользовательский опыт, позволяя быстро и безопасно разблокировать приложение. Реализация следует лучшим практикам Android Security и использует надежные криптографические методы. diff --git a/docs/BIOMETRIC_USER_GUIDE_RU.md b/docs/BIOMETRIC_USER_GUIDE_RU.md new file mode 100644 index 0000000..ee6e447 --- /dev/null +++ b/docs/BIOMETRIC_USER_GUIDE_RU.md @@ -0,0 +1,79 @@ +# Как использовать биометрическую аутентификацию + +## Первичная настройка + +1. **Откройте приложение и войдите с паролем** + - Выберите свой аккаунт + - Введите пароль + - Нажмите "Unlock" + +2. **Включите биометрию** + - Под кнопкой "Unlock" появится опция "Включить биометрию" + - Нажмите на неё + - Биометрия будет включена + +3. **Готово!** + - При следующем входе приложение автоматически предложит использовать отпечаток пальца / Face ID + - Больше не нужно вводить пароль + +## Вход с биометрией + +1. **Откройте приложение** + - Автоматически появится диалог биометрической аутентификации +2. **Подтвердите биометрию** + - Приложите палец к сканеру или посмотрите в камеру + - Приложение разблокируется автоматически + +3. **Альтернатива** + - Можно нажать "Использовать пароль" в диалоге биометрии + - Или отключить биометрию и ввести пароль вручную + +## Отключение биометрии + +1. **На экране входа** + - Нажмите на переключатель "Биометрия включена" + - Биометрия будет отключена + - Зашифрованный пароль будет удален + +2. **При следующем входе** + - Нужно будет ввести пароль вручную + +## Требования + +- Устройство должно поддерживать биометрическую аутентификацию +- В настройках системы должны быть настроены отпечаток пальца или Face ID +- Android 7.0 или новее + +## Безопасность + +✅ **Ваш пароль надежно защищен** + +- Пароль шифруется специальным ключом в Android Keystore +- Расшифровка возможна только через биометрию +- При смене отпечатков/Face ID нужно заново включить биометрию + +✅ **Конфиденциальность** + +- Биометрические данные хранятся только в системе Android +- Приложение не имеет доступа к вашим отпечаткам +- Используется только для подтверждения личности + +## Troubleshooting + +### Биометрия не работает + +- Убедитесь, что биометрия настроена в системных настройках +- Попробуйте отключить и снова включить биометрию в приложении +- Проверьте, что сканер отпечатков чистый + +### Приложение не предлагает биометрию + +- Возможно, устройство не поддерживает биометрию +- Убедитесь, что добавлен хотя бы один отпечаток/Face ID в системных настройках +- Попробуйте перезапустить приложение + +### После обновления системы биометрия не работает + +- Это нормально при смене биометрических данных +- Войдите с паролем +- Заново включите биометрию