Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования

Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore
(setUserAuthenticationRequired + setInvalidatedByBiometricEnrollment,
per-use CryptoObject, префикс v2b:): ПУБЛИЧНЫЙ ключ шифрует без авторизации
(в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает
ТОЛЬКО через BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы:
там auth требуется и на шифрование.

Разрешение конфликта с фоновыми push/звонками: пароль расшифровывается ОДИН
раз при разблокировке (AppLockGate) и живёт в памяти процесса
(AppPasswordHolder, не на диске). AuthStore.load() в фоне возвращает null,
пока «холодно» → существующие `?: return` в push/звонках деградируют сами;
экраны Accept (CallIncomingActivity, TalkPushBridge) открывают приложение
для разблокировки вместо тихого отказа. AppScaffold через loadMetadata()
показывает экран блокировки, а не логин, и перечитывает сессию после разблокировки.

ВЫКЛ по умолчанию (AppLockStore.isPasswordBindingEnabled, только при
биометрии-без-PIN — единственный путь разблокировки прогревает holder).
UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения
«после ребута до первой разблокировки звонки/push не работают» (как в Signal).

v0.5.115 (123). Проверено сборкой assembleRelease на инфре B: APK 54.6 МБ,
подпись v2+v3 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
b-dev-mobile
2026-07-07 20:49:45 +00:00
parent 6abb1b15c3
commit 321f81bffe
11 changed files with 335 additions and 37 deletions
@@ -66,12 +66,27 @@ class AppLockStore(context: Context) {
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
/**
* Биометрическая привязка app password (auth-bound ключ + кэш в памяти). ВЫКЛ по умолчанию.
* Разрешена только при биометрии-без-PIN: единственный путь разблокировки — биометрия, значит
* при каждой разблокировке гарантированно прогревается [AppPasswordHolder] через CryptoObject.
* Меняет фоновое поведение (звонки/push после ребута до первой разблокировки не работают) —
* включать через настройку только после подтверждения владельцем.
*/
fun isPasswordBindingEnabled(): Boolean =
prefs.getBoolean(KEY_BIND_PASSWORD, false) && isEnabled() && isBiometricOnly()
fun setPasswordBinding(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BIND_PASSWORD, enabled).apply()
}
fun disable() {
prefs.edit()
.putBoolean(KEY_ENABLED, false)
.remove(KEY_PIN_HASH)
.putBoolean(KEY_BIOMETRIC, false)
.putBoolean(KEY_BIOMETRIC_ONLY, false)
.putBoolean(KEY_BIND_PASSWORD, false)
.apply()
}
@@ -95,6 +110,7 @@ class AppLockStore(context: Context) {
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
private const val KEY_PIN_HASH = "pin_hash"
private const val KEY_SETUP_OFFERED = "setup_offered"
private const val KEY_BIND_PASSWORD = "bind_password"
@Volatile
private var coldStartPending = true
@@ -0,0 +1,38 @@
package ru.forbion.f7cloud.core.auth
/**
* Process-memory кэш расшифрованного app password для режима биометрической привязки
* (см. [KeystoreCrypto] auth-bound ключ и [AppLockStore.isPasswordBindingEnabled]).
*
* Когда пароль зашифрован ключом, требующим биометрию, его нельзя расшифровать в фоне
* (push, входящие звонки, холодный старт) — там некому пройти BiometricPrompt. Поэтому пароль
* расшифровывается ОДИН раз при разблокировке приложения (CryptoObject в AppLockGate) и живёт
* здесь, в памяти процесса. НИКОГДА не сериализуется на диск.
*
* «Тёплый» = пароль в памяти (приложение разблокировано после старта/ребута). «Холодный» =
* процесс только поднялся или приложение снова заблокировано → фоновые сценарии деградируют
* корректно («Разблокируйте приложение, чтобы принимать звонки»).
*
* При выключенной привязке (дефолт) holder не используется вовсе.
*/
object AppPasswordHolder {
@Volatile
private var password: String? = null
/** True, если пароль расшифрован и лежит в памяти (приложение разблокировано). */
val isWarm: Boolean
get() = password != null
/** Кладёт расшифрованный пароль в память (вызывается после успешной биометрии при разблокировке). */
fun set(value: String) {
password = value
}
/** Возвращает пароль из памяти или null, если «холодно» (не разблокировано в этом процессе). */
fun get(): String? = password
/** Забывает пароль (logout или повторная блокировка приложения). */
fun clear() {
password = null
}
}
@@ -3,14 +3,23 @@ package ru.forbion.f7cloud.core.auth
import android.content.Context
class AuthStore(context: Context) {
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
private val appContext = context.applicationContext
private val prefs = appContext.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
private val lockStore by lazy { AppLockStore(appContext) }
fun save(session: AuthSession) {
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto).
// При включённой биометрической привязке — auth-bound ключом (расшифровка требует
// биометрии), иначе — обычным AES-256-GCM.
val encrypted = if (lockStore.isPasswordBindingEnabled()) {
KeystoreCrypto.encryptAuthBound(session.appPassword)
} else {
KeystoreCrypto.encrypt(session.appPassword)
}
prefs.edit()
.putString("server_url", session.serverUrl.trimEnd('/'))
.putString("username", session.username.trim())
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto)
.putString("app_password", KeystoreCrypto.encrypt(session.appPassword))
.putString("app_password", encrypted)
.putBoolean("trust_all_certs", session.trustAllCerts)
.putString("dav_user_id", session.davUserId)
.apply()
@@ -21,13 +30,19 @@ class AuthStore(context: Context) {
val username = prefs.getString("username", null) ?: return null
val storedPassword = prefs.getString("app_password", null) ?: return null
val appPassword = if (KeystoreCrypto.isEncrypted(storedPassword)) {
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
} else {
// Legacy: пароль сохранён plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
storedPassword
val appPassword = when {
KeystoreCrypto.isAuthBound(storedPassword) -> {
// Привязано к биометрии: расшифровать в фоне нельзя. Берём из памяти, если
// приложение уже разблокировано в этом процессе; иначе «холодно» → сессии нет,
// фоновый вызывающий деградирует (`?: return`). Прогрев — в AppLockGate.
AppPasswordHolder.get() ?: return null
}
KeystoreCrypto.isEncrypted(storedPassword) -> {
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
}
else -> storedPassword // Legacy plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
}
val session = AuthSession(
@@ -37,13 +52,43 @@ class AuthStore(context: Context) {
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
davUserId = prefs.getString("dav_user_id", null),
)
if (!KeystoreCrypto.isEncrypted(storedPassword)) {
if (!KeystoreCrypto.isEncrypted(storedPassword) && !KeystoreCrypto.isAuthBound(storedPassword)) {
runCatching { save(session) } // одноразовая миграция plaintext → ciphertext
}
return session
}
/**
* Метаданные сессии для гейта блокировки: возвращает сессию даже когда пароль привязан и
* ещё не расшифрован (поле [AuthSession.appPassword] тогда пустое). null — только если входа
* нет вовсе. Отличает «залогинен, но заблокирован» от «не залогинен» для экрана блокировки.
*/
fun loadMetadata(): AuthSession? {
val serverUrl = prefs.getString("server_url", null) ?: return null
val username = prefs.getString("username", null) ?: return null
prefs.getString("app_password", null) ?: return null
return load() ?: AuthSession(
serverUrl = serverUrl,
username = username,
appPassword = "",
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
davUserId = prefs.getString("dav_user_id", null),
)
}
/** Пароль привязан к биометрии, но ещё не расшифрован в память (залогинен, но заблокирован). */
fun isPasswordLocked(): Boolean {
val stored = prefs.getString("app_password", null) ?: return false
return KeystoreCrypto.isAuthBound(stored) && !AppPasswordHolder.isWarm
}
/** Сырой auth-bound шифртекст пароля для расшифровки через CryptoObject (или null). */
fun boundPasswordCiphertext(): String? =
prefs.getString("app_password", null)?.takeIf { KeystoreCrypto.isAuthBound(it) }
fun clear() {
AppPasswordHolder.clear()
KeystoreCrypto.deleteBoundKey()
prefs.edit().clear().apply()
}
}
@@ -1,13 +1,21 @@
package ru.forbion.f7cloud.core.auth
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
/**
* Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM).
@@ -31,8 +39,19 @@ object KeystoreCrypto {
private const val TAG_BITS = 128
const val PREFIX = "v1:"
// --- Биометрически привязанный (auth-bound) вариант ---------------------------------------
// RSA-пара: ПУБЛИЧНЫЙ ключ шифрует без авторизации (можно в фоне — миграция/save после входа),
// ПРИВАТНЫЙ требует биометрию (setUserAuthenticationRequired) и расшифровывает ТОЛЬКО через
// BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы: там auth нужен и на шифрование.
private const val BIO_KEY_ALIAS = "f7_auth_master_bio"
private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
const val PREFIX_BIO = "v2b:"
fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX)
/** True для шифртекста, привязанного к биометрии (расшифровка требует BiometricPrompt). */
fun isAuthBound(value: String?): Boolean = value != null && value.startsWith(PREFIX_BIO)
/** Шифрует строку; результат — с префиксом [PREFIX]. */
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
@@ -76,4 +95,87 @@ object KeystoreCrypto {
)
return generator.generateKey()
}
// --- Auth-bound (RSA) ----------------------------------------------------------------------
/**
* Шифрует пароль публичным ключом привязанной пары. Авторизация НЕ требуется (публичный ключ),
* поэтому вызывается откуда угодно, включая фон. Результат — с префиксом [PREFIX_BIO].
*/
fun encryptAuthBound(plaintext: String): String {
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
// Публичный ключ из Keystore переупаковываем через дефолтный провайдер: иначе Cipher из
// AndroidKeyStore на init может «прицепить» auth-требование и к шифрованию.
val publicKey = getOrCreateBioEntry().certificate.publicKey
val unrestricted = KeyFactory.getInstance(publicKey.algorithm)
.generatePublic(X509EncodedKeySpec(publicKey.encoded))
cipher.init(Cipher.ENCRYPT_MODE, unrestricted, oaepParams())
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return PREFIX_BIO + Base64.encodeToString(ct, Base64.NO_WRAP)
}
/**
* Готовит Cipher для расшифровки привязанного шифртекста (DECRYPT + приватный ключ). Его нужно
* обернуть в `BiometricPrompt.CryptoObject`, показать промпт и на успехе вызвать
* [finishBoundDecrypt]. Бросает, если ключ инвалидирован (сменилась биометрия/сброс) —
* вызывающий трактует как «нужен повторный вход».
*/
fun initBoundDecryptCipher(stored: String): Cipher {
require(isAuthBound(stored)) { "not an auth-bound value" }
val privateKey = getOrCreateBioEntry().privateKey as PrivateKey
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams())
return cipher
}
/**
* Завершает расшифровку после успешной биометрии — [cipher] должен прийти из
* `result.cryptoObject.cipher`, авторизованного пользователем.
*/
fun finishBoundDecrypt(cipher: Cipher, stored: String): String {
require(isAuthBound(stored)) { "not an auth-bound value" }
val ct = Base64.decode(stored.removePrefix(PREFIX_BIO), Base64.NO_WRAP)
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
/** Удаляет привязанный ключ (при отключении привязки/повторном входе). */
fun deleteBoundKey() {
runCatching {
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(BIO_KEY_ALIAS)
}
}
// OAEP с MGF1-SHA1 задаём явно на обеих сторонах: AndroidKeyStore внутри использует SHA-1 для
// MGF1 даже при digest SHA-256 — без этого расшифровка падает несовпадением параметров.
private fun oaepParams(): OAEPParameterSpec =
OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
private fun getOrCreateBioEntry(): KeyStore.PrivateKeyEntry {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(ks.getEntry(BIO_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry)?.let { return it }
val generator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_RSA,
ANDROID_KEYSTORE,
)
val builder = KeyGenParameterSpec.Builder(
BIO_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(2048)
.setDigests(KeyProperties.DIGEST_SHA256)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.setUserAuthenticationRequired(true)
// Смена/добавление биометрии инвалидирует ключ → пароль недоступен → повторный вход.
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Окно валидности 0 = авторизация на КАЖДОЕ использование (через CryptoObject),
// только сильная биометрия (Class 3) — обязательна для CryptoObject в Keystore.
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
}
// На API < 30 отсутствие setUserAuthenticationValidityDurationSeconds(>0) уже означает
// per-use auth, совместимую с CryptoObject.
generator.initialize(builder.build())
generator.generateKeyPair()
return ks.getEntry(BIO_KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
}
}