Этап 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
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 122
versionName '0.5.114'
versionCode 123
versionName '0.5.115'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
@@ -86,7 +86,25 @@ class CallIncomingActivity : ComponentActivity() {
private fun acceptCall(launch: IncomingCallLaunch) {
F7IncomingCallRinger.stop()
val session = AuthStore(this).load() ?: return
val authStore = AuthStore(this)
val session = authStore.load()
if (session == null) {
// При биопривязке пароль расшифрован только после разблокировки приложения. Если
// «холодно» (после ребута/повторной блокировки) — открываем приложение, чтобы человек
// прошёл биометрию, а не молча роняем звонок.
if (authStore.isPasswordLocked()) {
android.widget.Toast.makeText(
this,
"Разблокируйте приложение, чтобы принять звонок",
android.widget.Toast.LENGTH_LONG,
).show()
packageManager.getLaunchIntentForPackage(packageName)?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(it)
}
}
return
}
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
TalkNativeCallLauncher.launchIncomingCall(
this,
@@ -1,5 +1,6 @@
package ru.forbion.f7cloud.mobile.ui
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
@@ -51,6 +52,9 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.auth.AppPasswordHolder
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.auth.KeystoreCrypto
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
@@ -63,6 +67,7 @@ private enum class AppLockSetupStage {
}
private const val PIN_LENGTH = 4
private const val TAG = "AppLockGate"
private fun biometricAuthenticators(): Int =
BiometricManager.Authenticators.BIOMETRIC_STRONG or
@@ -76,6 +81,7 @@ private fun canUseBiometric(context: android.content.Context): Boolean =
fun AppLockGate(
lockStore: AppLockStore,
unlockNonce: Int = 0,
onUnlocked: () -> Unit = {},
content: @Composable () -> Unit,
) {
if (!lockStore.isEnabled()) {
@@ -85,10 +91,17 @@ fun AppLockGate(
var unlocked by remember { mutableStateOf(false) }
var lockSession by remember { mutableIntStateOf(0) }
// При повторной блокировкe забываем расшифрованный пароль из памяти: заблокированное
// приложение не должно держать секрет резидентно (фоновые сценарии деградируют до разблокировки).
fun relock() {
AppPasswordHolder.clear()
unlocked = false
lockSession++
}
LaunchedEffect(Unit) {
if (lockStore.consumeColdStart()) {
unlocked = false
lockSession++
relock()
}
}
@@ -99,14 +112,8 @@ fun AppLockGate(
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
Lifecycle.Event.ON_START -> {
when {
lockStore.consumeColdStart() -> {
unlocked = false
lockSession++
}
lockStore.shouldRequireUnlock() -> {
unlocked = false
lockSession++
}
lockStore.consumeColdStart() -> relock()
lockStore.shouldRequireUnlock() -> relock()
}
}
else -> Unit
@@ -133,6 +140,7 @@ fun AppLockGate(
onUnlocked = {
lockStore.clearBackgroundMarker()
unlocked = true
onUnlocked()
},
)
}
@@ -542,6 +550,12 @@ private fun AppLockUnlockScreen(
val biometricOnly = lockStore.isBiometricOnly()
val biometricAvailable = remember { canUseBiometric(context) }
// Биометрическая привязка пароля: при разблокировке расшифровываем app password через
// CryptoObject и кладём в память (AppPasswordHolder), чтобы фоновые сценарии его читали.
val authStore = remember { AuthStore(context) }
val bindingEnabled = lockStore.isPasswordBindingEnabled()
val boundCipherText = remember(lockSession) { authStore.boundPasswordCiphertext() }
fun verifyPinInput() {
if (lockStore.verifyPin(pin)) {
onUnlocked()
@@ -572,12 +586,39 @@ private fun AppLockUnlockScreen(
error = "Биометрия недоступна"
return
}
// Режим привязки: готовим Cipher для расшифровки пароля. Если ключ инвалидирован (сменилась
// биометрия устройства) — привязку сбрасываем, чтобы не запереть пользователя навсегда;
// приложение попросит войти заново (пароль восстановить нельзя).
val useCrypto = bindingEnabled && boundCipherText != null
val decryptCipher = if (useCrypto) {
runCatching { KeystoreCrypto.initBoundDecryptCipher(boundCipherText) }.getOrElse {
Log.w(TAG, "Bound key invalidated, resetting binding", it)
lockStore.setPasswordBinding(false)
authStore.clear()
onUnlocked()
return
}
} else {
null
}
val executor = ContextCompat.getMainExecutor(context)
val prompt = BiometricPrompt(
host,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cipher = result.cryptoObject?.cipher
if (useCrypto && cipher != null) {
val ok = runCatching {
AppPasswordHolder.set(
KeystoreCrypto.finishBoundDecrypt(cipher, boundCipherText),
)
}.isSuccess
if (!ok) {
error = "Не удалось расшифровать пароль, войдите заново"
return
}
}
onUnlocked()
}
@@ -594,14 +635,23 @@ private fun AppLockUnlockScreen(
}
},
)
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Разблокировка F7cloud")
.setSubtitle("Прикоснитесь к сканеру отпечатка")
.setAllowedAuthenticators(biometricAuthenticators())
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
.build(),
)
// CryptoObject требует СИЛЬНОЙ биометрии (Class 3); без привязки допускаем и weak.
val authenticators = if (useCrypto) {
BiometricManager.Authenticators.BIOMETRIC_STRONG
} else {
biometricAuthenticators()
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Разблокировка F7cloud")
.setSubtitle("Прикоснитесь к сканеру отпечатка")
.setAllowedAuthenticators(authenticators)
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
.build()
if (decryptCipher != null) {
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(decryptCipher))
} else {
prompt.authenticate(promptInfo)
}
}
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
@@ -118,7 +118,9 @@ fun AppScaffold(
) {
val context = LocalContext.current
val authStore = remember { AuthStore(context) }
var session by remember { mutableStateOf(authStore.load()) }
// loadMetadata (не load): при биопривязке до разблокировки пароля нет, но сессия ЕСТЬ —
// показываем экран блокировки, а не логин. Полную сессию с паролем берём после разблокировки.
var session by remember { mutableStateOf(authStore.loadMetadata()) }
var activeTab by rememberSaveable(
saver = Saver(
save = { state -> state.value.name },
@@ -228,7 +230,13 @@ fun AppScaffold(
onDismiss = { showAppLockSetup = false },
onLockConfigured = { lockUnlockNonce++ },
)
AppLockGate(lockStore = lockStore, unlockNonce = lockUnlockNonce) {
AppLockGate(
lockStore = lockStore,
unlockNonce = lockUnlockNonce,
// После разблокировки (в т.ч. биометрией с CryptoObject) пароль уже в памяти —
// перечитываем полную сессию, чтобы сетевые вызовы получили app password.
onUnlocked = { authStore.load()?.let { session = it } },
) {
fun applyAppLink(target: AppLinkTarget) {
activeTab = target.tab
pendingTalkRoomToken = target.talkRoomToken