From 321f81bffeb4fdf64f5213a15605156b7d3fc2d6 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Tue, 7 Jul 2026 20:49:45 +0000 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=200=20=D0=BF.3=20(?= =?UTF-8?q?=D0=B4=D0=BE=D0=BF):=20=D0=B1=D0=B8=D0=BE=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20app=20password=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20=D1=88=D0=B8=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + INTEGRATION.md | 11 +- app/build.gradle | 4 +- .../f7cloud/mobile/CallIncomingActivity.kt | 20 +++- .../forbion/f7cloud/mobile/ui/AppLockGate.kt | 86 +++++++++++---- .../forbion/f7cloud/mobile/ui/AppScaffold.kt | 12 ++- .../forbion/f7cloud/core/auth/AppLockStore.kt | 16 +++ .../f7cloud/core/auth/AppPasswordHolder.kt | 38 +++++++ .../ru/forbion/f7cloud/core/auth/AuthStore.kt | 67 ++++++++++-- .../f7cloud/core/auth/KeystoreCrypto.kt | 102 ++++++++++++++++++ .../feature/talknative/TalkPushBridge.kt | 15 ++- 11 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordHolder.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c96648..f404cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` +- 2026-07-07 | v0.5.115 (123) | Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования в покое. Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore (`setUserAuthenticationRequired`, `setInvalidatedByBiometricEnrollment`, per-use CryptoObject, префикс `v2b:`): ПУБЛИЧНЫЙ ключ шифрует без авторизации (в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает ТОЛЬКО через BiometricPrompt+CryptoObject. Расшифровка ОДИН раз при разблокировке (AppLockGate) → `AppPasswordHolder` в памяти процесса (не на диске). Разрешает конфликт с фоновыми push/звонками: `AuthStore.load()` в фоне возвращает null «холодным» → существующие `?: return` деградируют сами; экраны Accept открывают приложение для разблокировки вместо тихого отказа. ВЫКЛ по умолчанию (`AppLockStore.isPasswordBindingEnabled`, только при биометрии-без-PIN); UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения «после ребута до разблокировки звонки/push не работают» (как в Signal). Проверено сборкой assembleRelease на инфре B | контракты не менялись | средний: затрагивает хранилище пароля и путь разблокировки; по умолчанию поведение прежнее - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция) - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет - 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск diff --git a/INTEGRATION.md b/INTEGRATION.md index 522ca45..fb9e4da 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -62,10 +62,19 @@ legacy WebView-оболочки `android-webview`. ## Секреты (где, НЕ значения) - В git секретов нет. Учётные данные пользователя — только на устройстве (session storage `core/auth`). +- App password шифруется в покое ключом AndroidKeystore AES-256-GCM (`KeystoreCrypto`, + ключ `f7_auth_master`, префикс `v1:`). Опционально — **биометрическая привязка** + (`AppLockStore.isPasswordBindingEnabled`, ВЫКЛ по умолчанию): пароль шифруется + auth-bound RSA-парой `f7_auth_master_bio` (`setUserAuthenticationRequired`, префикс + `v2b:`), расшифровывается ТОЛЬКО через BiometricPrompt+CryptoObject один раз при + разблокировке и живёт в памяти процесса (`AppPasswordHolder`, не на диске). Фоновые + сценарии (push/входящие Talk-звонки) до первой разблокировки после ребута деградируют + корректно («Разблокируйте приложение…»). Включение по умолчанию/UI-тумблер — ПОСЛЕ + подтверждения владельцем допустимости этого поведения (как в Signal). - `google-services.json` (Firebase) — вне git; путь сборки документируется в PROJECT-STATUS.md. - Release-keystore для подписи — вне git (пока не настроен, сборки debug). ## Версия / changelog -- Текущее: v0.5.113 (code 121) — модуль «Задачи». История — CHANGELOG.md +- Текущее: v0.5.115 (code 123) — биопривязка app password (Этап 0 п.3 доп). История — CHANGELOG.md и git-история `app/build.gradle`. diff --git a/app/build.gradle b/app/build.gradle index 04c08e2..2c1f05f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt index 8592ab0..edc08e4 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt @@ -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, diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppLockGate.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppLockGate.kt index bf8dcaf..d7a48ff 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppLockGate.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppLockGate.kt @@ -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) { diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt index 4a72f50..571dfeb 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppScaffold.kt @@ -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 diff --git a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppLockStore.kt b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppLockStore.kt index 61c2248..b84a302 100644 --- a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppLockStore.kt +++ b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppLockStore.kt @@ -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 diff --git a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordHolder.kt b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordHolder.kt new file mode 100644 index 0000000..f8b1dfa --- /dev/null +++ b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordHolder.kt @@ -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 + } +} diff --git a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AuthStore.kt b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AuthStore.kt index 385c88c..f6da1b9 100644 --- a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AuthStore.kt +++ b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AuthStore.kt @@ -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() } } diff --git a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/KeystoreCrypto.kt b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/KeystoreCrypto.kt index f7a1cc9..d58d6f2 100644 --- a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/KeystoreCrypto.kt +++ b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/KeystoreCrypto.kt @@ -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 + } } diff --git a/feature/talk-native/src/main/java/ru/forbion/f7cloud/feature/talknative/TalkPushBridge.kt b/feature/talk-native/src/main/java/ru/forbion/f7cloud/feature/talknative/TalkPushBridge.kt index 5c25323..c909d88 100644 --- a/feature/talk-native/src/main/java/ru/forbion/f7cloud/feature/talknative/TalkPushBridge.kt +++ b/feature/talk-native/src/main/java/ru/forbion/f7cloud/feature/talknative/TalkPushBridge.kt @@ -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)