Files
mobile-android/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt
k1ngsterr1 9d3e5bcb10 Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и UX-фиксы
Что вошло:\n- Добавлен полноценный Messages-tab в SearchScreen: поиск по тексту сообщений по всей базе, батчевый проход, параллельная дешифровка, кеш расшифровки, подсветка совпадений, сниппеты и быстрый переход в нужный диалог.\n- В Chats-tab добавлены алиасы для Saved Messages (saved/saved messages/избранное/сохраненные и др.), чтобы чат открывался по текстовому поиску даже без точного username/public key.\n- Для search-бэкенда расширен DAO: getAllMessagesPaged() для постраничного обхода сообщений аккаунта.\n- Исправлена логика клика по @тэгам в сообщениях:\n  - переход теперь ведет сразу в чат пользователя (а не в профиль);\n  - добавлен fallback-резолв username -> user через локальный диалог, кеш протокола и PacketSearch;\n  - добавлен DAO getDialogByUsername() (регистронезависимо и с игнором @).\n- Усилена обработка PacketSearch в ProtocolManager:\n  - добавлена очередь ожидания pendingSearchQueries;\n  - нормализация query (без @, lowercase);\n  - устойчивый матч ответов сервера (raw/normalized/by username);\n  - добавлены методы getCachedUserByUsername() и searchUsers().\n- Исправлен конфликт тачей между ClickableSpan и bubble-menu:\n  - в AppleEmojiText/AppleEmojiTextView добавлен callback начала тапа по span;\n  - улучшен hit-test по span (включая пограничные offset/layout fallback);\n  - suppress performClick на span-тапах;\n  - в MessageBubble добавлен тайм-guard, чтобы tap по span не открывал context menu.\n- Стабилизирован verified-бейдж в заголовке чата: агрегируется из переданного user, кеша протокола, локальной БД и серверного resolve; отображается консистентно в личных чатах.\n- Улучшен пустой экран Saved Messages при обоях: добавлена аккуратная подложка/бордер и выровненный текст, чтобы контент оставался читабельным на любом фоне.\n- Реализована автосвязка обоев между светлой/темной темами:\n  - добавлены pairGroup и mapToTheme/resolveWallpaperForTheme в ThemeWallpapers;\n  - добавлены отдельные prefs-ключи для light/dark wallpaper;\n  - MainActivity теперь автоматически подбирает и сохраняет обои под активную тему и сохраняет выбор по теме.\n- Биометрия: если на устройстве нет hardware fingerprint, экран включения биометрии не показывается (и доступность возвращает NotAvailable).\n- Небольшие UI-фиксы: поправлено позиционирование галочки в сайдбаре.\n- Техдолг: удалена неиспользуемая зависимость jsoup из build.gradle.
2026-03-21 21:12:52 +05:00

588 lines
24 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.rosetta.messenger.biometric
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import java.security.cert.Certificate
import java.util.Arrays
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Безопасный менеджер биометрической аутентификации
*
* Использует Android Keystore для хранения ключей шифрования:
* - Ключи генерируются и хранятся в аппаратном модуле безопасности (TEE/StrongBox)
* - Ключи привязаны к биометрии через setUserAuthenticationRequired
* - Ключи инвалидируются при изменении биометрических данных
* - Используется AES-GCM для аутентифицированного шифрования
* - CryptoObject привязывает криптографические операции к биометрии
*/
class BiometricAuthManager(private val context: Context) {
companion object {
private const val TAG = "BiometricAuthManager"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "rosetta_biometric_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
private const val IV_SEPARATOR = "|"
// Google Hardware Attestation Root Certificate
private const val GOOGLE_ROOT_CA_FINGERPRINT =
"98:BD:5B:98:E9:D3:29:F4:CF:10:45:76:A5:00:7D:A8:08:7A:70:50:4D:FF:4D:C5:95:3A:7F:C3:73:79:0F:ED"
}
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
load(null)
}
/**
* Проверяет доступность STRONG биометрической аутентификации
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
*/
fun isFingerprintHardwareAvailable(): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
}
fun isBiometricAvailable(): BiometricAvailability {
if (!isFingerprintHardwareAvailable()) {
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
}
val biometricManager = BiometricManager.from(context)
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
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
* Шифрование привязано к биометрии через CryptoObject
*/
fun encryptPassword(
activity: FragmentActivity,
password: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
) {
try {
// Генерируем или получаем ключ
val secretKey = getOrCreateSecretKey()
// Создаем Cipher для шифрования
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
// Показываем биометрический промпт с CryptoObject
showBiometricPromptWithCrypto(
activity = activity,
cipher = cipher,
title = "Сохранить пароль",
subtitle = "Подтвердите биометрией для защиты пароля",
onSuccess = { authenticatedCipher ->
try {
val passwordBytes = password.toByteArray(Charsets.UTF_8)
try {
val encrypted = authenticatedCipher.doFinal(passwordBytes)
val iv = authenticatedCipher.iv
// Сохраняем IV и зашифрованные данные
val ivString = Base64.encodeToString(iv, Base64.NO_WRAP)
val encryptedString = Base64.encodeToString(encrypted, Base64.NO_WRAP)
onSuccess("$ivString$IV_SEPARATOR$encryptedString")
} finally {
// Secure memory wipe - обнуляем пароль в памяти
Arrays.fill(passwordBytes, 0.toByte())
}
} catch (e: Exception) {
onError("Ошибка шифрования: ${e.message}")
}
},
onError = onError,
onCancel = onCancel
)
} catch (e: KeyPermanentlyInvalidatedException) {
removeBiometricData()
onError("Биометрические данные изменились. Пожалуйста, настройте заново.")
} catch (e: Exception) {
onError("Ошибка инициализации: ${formatInitializationError(e)}")
}
}
/**
* Расшифровывает пароль с использованием ключа из Android Keystore
* Расшифровка возможна только после успешной биометрической аутентификации
*/
fun decryptPassword(
activity: FragmentActivity,
encryptedData: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
) {
try {
val parts = encryptedData.split(IV_SEPARATOR)
if (parts.size != 2) {
onError("Неверный формат зашифрованных данных")
return
}
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val encrypted = Base64.decode(parts[1], Base64.NO_WRAP)
// Получаем ключ из Keystore
val secretKey = getSecretKey()
if (secretKey == null) {
onError("Ключ не найден. Пожалуйста, настройте биометрию заново.")
return
}
// Создаем Cipher для расшифровки с тем же IV
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
// Показываем биометрический промпт с CryptoObject
showBiometricPromptWithCrypto(
activity = activity,
cipher = cipher,
title = "Разблокировать",
subtitle = "Подтвердите биометрией для входа",
onSuccess = { authenticatedCipher ->
var decrypted: ByteArray? = null
try {
decrypted = authenticatedCipher.doFinal(encrypted)
onSuccess(String(decrypted, Charsets.UTF_8))
} catch (e: Exception) {
onError("Ошибка расшифровки: ${e.message}")
} finally {
// Secure memory wipe - обнуляем расшифрованные данные
decrypted?.let { Arrays.fill(it, 0.toByte()) }
}
},
onError = onError,
onCancel = onCancel
)
} catch (e: KeyPermanentlyInvalidatedException) {
removeBiometricData()
onError("Биометрические данные изменились. Пожалуйста, войдите с паролем и настройте биометрию заново.")
} catch (e: Exception) {
onError("Ошибка инициализации: ${formatInitializationError(e)}")
}
}
/**
* Показывает биометрический промпт с криптографической привязкой (CryptoObject)
*/
private fun showBiometricPromptWithCrypto(
activity: FragmentActivity,
cipher: Cipher,
title: String,
subtitle: String,
onSuccess: (Cipher) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
) {
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
BiometricPrompt.ERROR_CANCELED -> onCancel()
BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT ->
onError("Слишком много попыток. Попробуйте позже.")
else -> onError(errString.toString())
}
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
// Получаем аутентифицированный Cipher из CryptoObject
val authenticatedCipher = result.cryptoObject?.cipher
if (authenticatedCipher != null) {
onSuccess(authenticatedCipher)
} else {
onError("Ошибка: криптографический объект не получен")
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// Не вызываем onError - пользователь может попробовать снова
}
}
val biometricPrompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText("Отмена")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setConfirmationRequired(false)
.build()
// Аутентификация с CryptoObject - криптографическая операция привязана к биометрии
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
/**
* Генерирует новый ключ или возвращает существующий из Android Keystore
*/
private fun getOrCreateSecretKey(): SecretKey {
// Проверяем, есть ли уже ключ
getSecretKey()?.let { return it }
val attempts = buildKeyGenerationAttempts()
var lastError: Exception? = null
for ((index, config) in attempts.withIndex()) {
try {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
val spec = buildKeyGenSpec(config)
keyGenerator.init(spec)
return keyGenerator.generateKey()
} catch (e: Exception) {
lastError = e
Log.w(
TAG,
"Key generation attempt ${index + 1}/${attempts.size} failed " +
"(strongBox=${config.useStrongBox}, attestation=${config.useAttestation}, " +
"unlockedRequired=${config.requireUnlockedDevice})",
e
)
removeKeyAliasSilently()
}
}
throw IllegalStateException("Failed to generate key", lastError)
}
private fun buildKeyGenerationAttempts(): List<KeyGenerationConfig> {
val attempts = mutableListOf<KeyGenerationConfig>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
attempts += KeyGenerationConfig(
useStrongBox = true,
useAttestation = true,
requireUnlockedDevice = true
)
}
attempts += KeyGenerationConfig(
useStrongBox = false,
useAttestation = true,
requireUnlockedDevice = true
)
attempts += KeyGenerationConfig(
useStrongBox = false,
useAttestation = false,
requireUnlockedDevice = true
)
attempts += KeyGenerationConfig(
useStrongBox = false,
useAttestation = false,
requireUnlockedDevice = false
)
return attempts
}
private fun buildKeyGenSpec(config: KeyGenerationConfig): KeyGenParameterSpec {
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
if (config.requireUnlockedDevice && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUnlockedDeviceRequired(true)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG
)
}
if (config.useAttestation && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setAttestationChallenge(generateAttestationChallenge())
}
if (config.useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
return builder.build()
}
private fun removeKeyAliasSilently() {
try {
if (keyStore.containsAlias(KEY_ALIAS)) {
keyStore.deleteEntry(KEY_ALIAS)
}
} catch (_: Exception) {
}
}
private fun formatInitializationError(error: Throwable): String {
val rootCause = generateSequence(error) { it.cause }.lastOrNull() ?: error
val message = rootCause.message ?: error.message ?: "Unknown error"
return if (message.contains("Failed to generate key", ignoreCase = true)) {
"Не удалось создать ключ. Проверьте, что на устройстве включена блокировка экрана и настроена биометрия."
} else {
message
}
}
/**
* Получает существующий ключ из Keystore
*/
private fun getSecretKey(): SecretKey? {
return try {
keyStore.getKey(KEY_ALIAS, null) as? SecretKey
} catch (e: Exception) {
null
}
}
/**
* Удаляет ключ из Android Keystore
* Это делает все ранее зашифрованные данные недоступными
*/
fun removeBiometricData() {
try {
if (keyStore.containsAlias(KEY_ALIAS)) {
keyStore.deleteEntry(KEY_ALIAS)
}
} catch (e: Exception) {
}
}
/**
* Проверяет, есть ли сохраненный ключ
*/
fun hasStoredKey(): Boolean {
return try {
keyStore.containsAlias(KEY_ALIAS)
} catch (e: Exception) {
false
}
}
/**
* Генерирует случайный challenge для Key Attestation
*/
private fun generateAttestationChallenge(): ByteArray {
val challenge = ByteArray(32)
java.security.SecureRandom().nextBytes(challenge)
return challenge
}
/**
* Проверяет Key Attestation - убеждается что ключ реально в TEE/StrongBox
* Возвращает true если attestation валидный или недоступен (fallback)
*/
fun verifyKeyAttestation(): KeyAttestationResult {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return KeyAttestationResult.NotSupported
}
return try {
val certificateChain = keyStore.getCertificateChain(KEY_ALIAS)
if (certificateChain.isNullOrEmpty()) {
return KeyAttestationResult.NotSupported
}
// Проверяем что цепочка валидна
for (i in 0 until certificateChain.size - 1) {
try {
certificateChain[i].verify(certificateChain[i + 1].publicKey)
} catch (e: Exception) {
return KeyAttestationResult.Invalid("Цепочка сертификатов недействительна")
}
}
// Проверяем attestation extension в leaf сертификате
val leafCert = certificateChain[0] as? java.security.cert.X509Certificate
if (leafCert != null) {
val attestationExtension = leafCert.getExtensionValue("1.3.6.1.4.1.11129.2.1.17")
if (attestationExtension != null) {
return KeyAttestationResult.Valid(isStrongBox = isKeyInStrongBox())
}
}
KeyAttestationResult.NotSupported
} catch (e: Exception) {
KeyAttestationResult.Invalid(e.message ?: "Unknown error")
}
}
/**
* Проверяет находится ли ключ в StrongBox
*/
private fun isKeyInStrongBox(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
return try {
val key = keyStore.getKey(KEY_ALIAS, null)
val keyInfo = java.security.KeyFactory.getInstance(
key.algorithm, KEYSTORE_PROVIDER
).getKeySpec(key, android.security.keystore.KeyInfo::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
keyInfo.securityLevel == KeyProperties.SECURITY_LEVEL_STRONGBOX
} else {
@Suppress("DEPRECATION")
keyInfo.isInsideSecureHardware
}
} catch (e: Exception) {
false
}
}
/**
* Проверяет целостность устройства (root detection)
*/
fun checkDeviceIntegrity(): DeviceIntegrityResult {
val issues = mutableListOf<String>()
// Проверка на root
val rootIndicators = listOf(
"/system/app/Superuser.apk",
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/system/sd/xbin/su",
"/system/bin/failsafe/su",
"/data/local/su",
"/su/bin/su",
"/system/xbin/daemonsu",
"/system/bin/.ext/.su",
"/system/etc/init.d/99SuperSUDaemon",
"/dev/com.koushikdutta.superuser.daemon/",
"/system/app/Superuser",
"/system/app/SuperSU",
"/system/app/Magisk"
)
for (path in rootIndicators) {
if (java.io.File(path).exists()) {
issues.add("Root indicator found: $path")
break
}
}
// Проверка Build tags
val buildTags = Build.TAGS
if (buildTags != null && buildTags.contains("test-keys")) {
issues.add("Device has test-keys")
}
// Проверка dangerous props
try {
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", "ro.debuggable"))
val reader = java.io.BufferedReader(java.io.InputStreamReader(process.inputStream))
val debuggable = reader.readLine()
reader.close()
if (debuggable == "1") {
issues.add("Device is debuggable")
}
} catch (e: Exception) {
// Ignore
}
return if (issues.isEmpty()) {
DeviceIntegrityResult.Secure
} else {
DeviceIntegrityResult.Compromised(issues)
}
}
}
private data class KeyGenerationConfig(
val useStrongBox: Boolean,
val useAttestation: Boolean,
val requireUnlockedDevice: Boolean
)
/**
* Результат проверки Key Attestation
*/
sealed class KeyAttestationResult {
data class Valid(val isStrongBox: Boolean) : KeyAttestationResult()
data class Invalid(val reason: String) : KeyAttestationResult()
object NotSupported : KeyAttestationResult()
}
/**
* Результат проверки целостности устройства
*/
sealed class DeviceIntegrityResult {
object Secure : DeviceIntegrityResult()
data class Compromised(val issues: List<String>) : DeviceIntegrityResult()
}
/**
* Результат проверки доступности биометрии
*/
sealed class BiometricAvailability {
object Available : BiometricAvailability()
data class NotAvailable(val reason: String) : BiometricAvailability()
data class NotEnrolled(val reason: String) : BiometricAvailability()
}