Этап 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:
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user