feat: Implement BiometricAuthManager for biometric authentication and password encryption/decryption
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : FragmentActivity() {
|
||||||
private lateinit var preferencesManager: PreferencesManager
|
private lateinit var preferencesManager: PreferencesManager
|
||||||
private lateinit var accountManager: AccountManager
|
private lateinit var accountManager: AccountManager
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package com.rosetta.messenger.biometric
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Менеджер для работы с биометрической аутентификацией
|
||||||
|
* Поддерживает отпечаток пальца, Face ID и другие биометрические методы
|
||||||
|
*/
|
||||||
|
class BiometricAuthManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_NAME = "rosetta_biometric_key"
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_SEPARATOR = "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет доступность биометрической аутентификации на устройстве
|
||||||
|
*/
|
||||||
|
fun isBiometricAvailable(): BiometricAvailability {
|
||||||
|
val biometricManager = BiometricManager.from(context)
|
||||||
|
|
||||||
|
return when (biometricManager.canAuthenticate(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||||
|
)) {
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS ->
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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<Preferences> 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<Boolean> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,11 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.rosetta.messenger.R
|
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.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
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.getAvatarText
|
||||||
import com.rosetta.messenger.ui.chats.utils.getInitials
|
import com.rosetta.messenger.ui.chats.utils.getInitials
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
// Account model for dropdown
|
// Account model for dropdown
|
||||||
data class AccountItem(
|
data class AccountItem(
|
||||||
@@ -52,6 +58,87 @@ data class AccountItem(
|
|||||||
val encryptedAccount: EncryptedAccount
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun UnlockScreen(
|
fun UnlockScreen(
|
||||||
@@ -84,7 +171,10 @@ fun UnlockScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val activity = context as? FragmentActivity
|
||||||
val accountManager = remember { AccountManager(context) }
|
val accountManager = remember { AccountManager(context) }
|
||||||
|
val biometricManager = remember { BiometricAuthManager(context) }
|
||||||
|
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
@@ -92,6 +182,10 @@ fun UnlockScreen(
|
|||||||
var isUnlocking by remember { mutableStateOf(false) }
|
var isUnlocking by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Биометрия
|
||||||
|
var biometricAvailable by remember { mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Проверка...")) }
|
||||||
|
var isBiometricEnabled by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Account selection state
|
// Account selection state
|
||||||
var accounts by remember { mutableStateOf<List<AccountItem>>(emptyList()) }
|
var accounts by remember { mutableStateOf<List<AccountItem>>(emptyList()) }
|
||||||
var selectedAccount by remember { mutableStateOf<AccountItem?>(null) }
|
var selectedAccount by remember { mutableStateOf<AccountItem?>(null) }
|
||||||
@@ -125,6 +219,56 @@ fun UnlockScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedAccount = targetAccount ?: accounts.firstOrNull()
|
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
|
// Filter accounts by search
|
||||||
@@ -571,6 +715,7 @@ fun UnlockScreen(
|
|||||||
visible = visible && !isDropdownExpanded,
|
visible = visible && !isDropdownExpanded,
|
||||||
enter = fadeIn(tween(400, delayMillis = 500))
|
enter = fadeIn(tween(400, delayMillis = 500))
|
||||||
) {
|
) {
|
||||||
|
Column {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (selectedAccount == null) {
|
if (selectedAccount == null) {
|
||||||
@@ -582,82 +727,37 @@ fun UnlockScreen(
|
|||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
|
|
||||||
isUnlocking = true
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
performUnlock(
|
||||||
val account = selectedAccount!!.encryptedAccount
|
selectedAccount = selectedAccount,
|
||||||
|
password = password,
|
||||||
// Try to decrypt
|
accountManager = accountManager,
|
||||||
val decryptedPrivateKey =
|
onUnlocking = { isUnlocking = it },
|
||||||
CryptoManager.decryptWithPassword(
|
onError = { error = it },
|
||||||
account.encryptedPrivateKey,
|
onSuccess = { decryptedAccount ->
|
||||||
password
|
// Если биометрия доступна и включена, сохраняем пароль
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
if (decryptedPrivateKey == null) {
|
|
||||||
error = "Incorrect password"
|
|
||||||
isUnlocking = false
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
val decryptedSeedPhrase =
|
onError = { /* Игнорируем ошибки при сохранении */ },
|
||||||
CryptoManager.decryptWithPassword(
|
onCancel = { /* Пользователь отменил */ }
|
||||||
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)
|
onUnlocked(decryptedAccount)
|
||||||
} catch (e: Exception) {
|
|
||||||
error = "Failed to unlock: \${e.message}"
|
|
||||||
isUnlocking = false
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
|
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
|
||||||
@@ -688,9 +788,153 @@ fun UnlockScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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
|
// Create New Account button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible && !isDropdownExpanded,
|
visible = visible && !isDropdownExpanded,
|
||||||
|
|||||||
@@ -335,6 +335,46 @@ fun MessageBubble(
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если есть reply - текст слева, время справа на одной строке
|
||||||
|
if (message.replyData != null) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
AppleEmojiText(
|
||||||
|
text = message.text,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@@ -374,6 +414,7 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Animated message status indicator */
|
/** Animated message status indicator */
|
||||||
|
|||||||
206
docs/BIOMETRIC_AUTHENTICATION.md
Normal file
206
docs/BIOMETRIC_AUTHENTICATION.md
Normal file
@@ -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
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Хранение данных
|
||||||
|
|
||||||
|
- Зашифрованные пароли хранятся в 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 и использует надежные криптографические методы.
|
||||||
79
docs/BIOMETRIC_USER_GUIDE_RU.md
Normal file
79
docs/BIOMETRIC_USER_GUIDE_RU.md
Normal file
@@ -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 в системных настройках
|
||||||
|
- Попробуйте перезапустить приложение
|
||||||
|
|
||||||
|
### После обновления системы биометрия не работает
|
||||||
|
|
||||||
|
- Это нормально при смене биометрических данных
|
||||||
|
- Войдите с паролем
|
||||||
|
- Заново включите биометрию
|
||||||
Reference in New Issue
Block a user