Этап 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
@@ -14,9 +14,20 @@ object TalkPushBridge {
private const val TAG = "TalkPushBridge"
fun launchAcceptedCall(context: Context, acceptUrl: String) {
val session = AuthStore(context).load()
val authStore = AuthStore(context)
val session = authStore.load()
if (session == null) {
Log.w(TAG, "No session for accepted call")
// Биопривязка + «холодно»: пароль ещё не разблокирован. Открываем приложение на экране
// блокировки, чтобы пользователь прошёл биометрию, вместо тихого отказа.
if (authStore.isPasswordLocked()) {
Log.w(TAG, "Password locked, opening app to unlock before accepting call")
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(it)
}
} else {
Log.w(TAG, "No session for accepted call")
}
return
}
TalkNativeCallLauncher.launchIncomingCall(context, session, acceptUrl)