Этап 0 п.3: безопасность-минимум (trust-all off в release, шифрование пароля, allowBackup, ревокация)

1. trust-all TLS отключён в release: гейт if(!BuildConfig.DEBUG) в UnsafeSsl +
   OfficeWebViewClient + TalkCallActivity; чекбокс скрыт в release. buildConfig
   включён в core:network и feature:talk.
2. allowBackup=false (tools:replace поверх vendor true).
3. app password шифруется в покое AndroidKeystore AES-256-GCM (KeystoreCrypto),
   AuthStore мигрирует legacy-plaintext при первом load. Биометрия — отдельная
   задача (ломает фоновые push/звонки, нужен кэш в памяти).
4. Ревокация app password при logout (AppPasswordRevoker, DELETE core/apppassword,
   best-effort).

Проверено: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false в манифесте OK.
This commit is contained in:
b-dev-mobile
2026-07-07 19:51:23 +00:00
parent c259936e82
commit 51fd1bf936
12 changed files with 167 additions and 20 deletions
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.core.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.applyOcsJson
/**
* Ревокация app password на сервере при выходе (`DELETE /ocs/v2.php/core/apppassword`).
*
* Best-effort: если офлайн/ошибка — выход всё равно проходит (локальные данные чистятся
* вызывающим), протухший токен не критичен. Вызывать ДО [AuthStore.clear].
*/
object AppPasswordRevoker {
suspend fun revoke(session: AuthSession): Boolean = withContext(Dispatchers.IO) {
runCatching {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/core/apppassword?format=json")
.applyOcsJson()
.delete()
.build()
client.newCall(request).execute().use { it.isSuccessful }
}.getOrDefault(false)
}
}
@@ -9,7 +9,8 @@ class AuthStore(context: Context) {
prefs.edit()
.putString("server_url", session.serverUrl.trimEnd('/'))
.putString("username", session.username.trim())
.putString("app_password", session.appPassword)
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto)
.putString("app_password", KeystoreCrypto.encrypt(session.appPassword))
.putBoolean("trust_all_certs", session.trustAllCerts)
.putString("dav_user_id", session.davUserId)
.apply()
@@ -18,14 +19,28 @@ class AuthStore(context: Context) {
fun load(): AuthSession? {
val serverUrl = prefs.getString("server_url", null) ?: return null
val username = prefs.getString("username", null) ?: return null
val appPassword = prefs.getString("app_password", null) ?: return null
return AuthSession(
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 session = AuthSession(
serverUrl = serverUrl,
username = username,
appPassword = appPassword,
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
davUserId = prefs.getString("dav_user_id", null),
)
if (!KeystoreCrypto.isEncrypted(storedPassword)) {
runCatching { save(session) } // одноразовая миграция plaintext → ciphertext
}
return session
}
fun clear() {
@@ -0,0 +1,79 @@
package ru.forbion.f7cloud.core.auth
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM).
*
* Ключ НЕэкспортируемый (на большинстве устройств — аппаратный TEE/StrongBox), поэтому даже
* утёкший ciphertext (бэкап, дамп prefs, root) бесполезен вне этого устройства.
*
* Пер-запросной авторизации пользователя НЕТ намеренно (`setUserAuthenticationRequired` не
* ставится): пароль читается в фоне (push, входящие звонки, холодный старт), где биометрию
* спросить не у кого. Биометрическая привязка — отдельная задача поверх (кэш в памяти +
* деградация фоновых сценариев).
*
* Формат выхода: `v1:` + Base64(IV[12] || ciphertext+tag). Префикс отличает шифртекст от
* legacy-plaintext при миграции.
*/
object KeystoreCrypto {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "f7_auth_master"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_SIZE = 12
private const val TAG_BITS = 128
const val PREFIX = "v1:"
fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX)
/** Шифрует строку; результат — с префиксом [PREFIX]. */
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = ByteArray(iv.size + ct.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(ct, 0, combined, iv.size, ct.size)
return PREFIX + Base64.encodeToString(combined, Base64.NO_WRAP)
}
/**
* Расшифровывает строку с префиксом [PREFIX]. Бросает, если ключ недоступен/инвалидирован
* (например, сброшены учётные данные устройства) или данные повреждены — вызывающий
* трактует это как «сессии нет» (повторный вход).
*/
fun decrypt(stored: String): String {
require(isEncrypted(stored)) { "not an encrypted value" }
val combined = Base64.decode(stored.removePrefix(PREFIX), Base64.NO_WRAP)
val iv = combined.copyOfRange(0, IV_SIZE)
val ct = combined.copyOfRange(IV_SIZE, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
(ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return generator.generateKey()
}
}
+3
View File
@@ -10,6 +10,9 @@ android {
defaultConfig {
minSdk 26
}
buildFeatures {
buildConfig true // нужен BuildConfig.DEBUG для гейта trust-all TLS (только debug)
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -8,6 +8,10 @@ import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
internal fun OkHttpClient.Builder.applyUnsafeSslIfNeeded(trustAllCerts: Boolean): OkHttpClient.Builder {
// trust-all (доверие любому TLS-сертификату) — ТОЛЬКО в debug-сборках. В release флаг
// игнорируется полностью, даже если сохранён в старой сессии (защита от MITM). Реальные
// тенанты F7 имеют валидный TLS; для self-signed — отдельная задача (пиннинг/user-CA).
if (!BuildConfig.DEBUG) return this
if (!trustAllCerts) return this
val trustAll = arrayOf<TrustManager>(
object : X509TrustManager {