1 Commits

Author SHA1 Message Date
b-dev-mobile 321f81bffe Этап 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>
2026-07-07 20:49:45 +00:00
11 changed files with 335 additions and 37 deletions
+1
View File
@@ -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 затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
+10 -1
View File
@@ -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`.
+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,11 +91,18 @@ fun AppLockGate(
var unlocked by remember { mutableStateOf(false) }
var lockSession by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
if (lockStore.consumeColdStart()) {
// При повторной блокировкe забываем расшифрованный пароль из памяти: заблокированное
// приложение не должно держать секрет резидентно (фоновые сценарии деградируют до разблокировки).
fun relock() {
AppPasswordHolder.clear()
unlocked = false
lockSession++
}
LaunchedEffect(Unit) {
if (lockStore.consumeColdStart()) {
relock()
}
}
val processLifecycle = ProcessLifecycleOwner.get()
@@ -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()
// CryptoObject требует СИЛЬНОЙ биометрии (Class 3); без привязки допускаем и weak.
val authenticators = if (useCrypto) {
BiometricManager.Authenticators.BIOMETRIC_STRONG
} else {
biometricAuthenticators()
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Разблокировка F7cloud")
.setSubtitle("Прикоснитесь к сканеру отпечатка")
.setAllowedAuthenticators(biometricAuthenticators())
.setAllowedAuthenticators(authenticators)
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
.build(),
)
.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
@@ -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)) {
val appPassword = when {
KeystoreCrypto.isAuthBound(storedPassword) -> {
// Привязано к биометрии: расшифровать в фоне нельзя. Берём из памяти, если
// приложение уже разблокировано в этом процессе; иначе «холодно» → сессии нет,
// фоновый вызывающий деградирует (`?: return`). Прогрев — в AppLockGate.
AppPasswordHolder.get() ?: return null
}
KeystoreCrypto.isEncrypted(storedPassword) -> {
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
} else {
// Legacy: пароль сохранён plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
storedPassword
}
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
}
}
@@ -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) {
// Биопривязка + «холодно»: пароль ещё не разблокирован. Открываем приложение на экране
// блокировки, чтобы пользователь прошёл биометрию, вместо тихого отказа.
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)