Этап 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:
@@ -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)) {
|
||||
// Расшифровка может упасть, если 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user