Этап 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
+1
View File
@@ -2,6 +2,7 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 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 затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.2 (размер APK): универсальный APK — abiFilters 'arm64-v8a','armeabi-v7a' (без эмуляторных x86/x86_64) + resourceConfigurations ['ru','en'] (у vendor было ~48 локалей). Проверено сборкой: assembleRelease → 53 МБ (было 99 с 4 ABI; ~130 debug), ABI внутри только ARM, подпись v2+v3 OK. RenderScript в vendor НЕ трогал (риск blur-эффектов, отдельный пункт) | контракты не менялись | низкий: только упаковка; функционал не тронут
- 2026-07-07 | v0.5.114 (122) | Аудит + Этап 0 п.1 (release собираем и подписан): добавлен signingConfig (ключ из env F7_KEYSTORE_*/-P, фолбэк на debug); написаны R8-правила — app/proguard-rules.pro (kotlinx.serialization/Firebase/JS-мосты) + vendor consumer-rules.pro через consumerProguardFiles (WebRTC/LoganSquare/Dagger/RxJava/Parceler/EventBus + dontwarn geogson/joda); подпись v2+v3. Проверено локально (JDK 21 + Android SDK 36 поставлены на инфру B): assembleRelease → BUILD SUCCESSFUL, подписанный APK 99 МБ (было ~130 debug). Полный аудит — docs/AUDIT-2026-07-07.md; подпись — docs/RELEASE-SIGNING.md | контракты не менялись; сборочный контур зафиксирован | низкий: код приложения не тронут, изменения только в сборке; APK всё ещё 4 ABI (ABI-split — п.2)
- 2026-07-07 | v0.5.114 (122) | Санитария по mail/035: дефолт-URL входа → BuildConfig.DEFAULT_SERVER_URL (свойство f7DefaultServerUrl, по умолчанию пусто); убран preconnect к боевому hpb-prod.f7cloud.ru (TalkHelper); нейтральный пример URL в тексте ошибки | INTEGRATION.md: зашитых касаний хостов A больше нет | низкий: preconnect — только оптимизация; сборка на билд-сервере ещё не прогонялась
+1
View File
@@ -30,6 +30,7 @@ legacy WebView-оболочки `android-webview`.
| Контракт | Что используется |
|---|---|
| OCS `/ocs/v2.php/cloud/user` | авторизация, `davUserId` (пароль/app-password при 2FA) |
| OCS `DELETE /ocs/v2.php/core/apppassword` | ревокация app password при logout (best-effort) |
| WebDAV | файлы (PROPFIND, `oc:fileid`) |
| CalDAV | календарь и задачи |
| CardDAV | контакты |
+2 -2
View File
@@ -22,13 +22,13 @@
<application
android:name=".F7MobileApp"
android:allowBackup="true"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:replace="android:label,android:theme,android:icon,android:roundIcon">
tools:replace="android:label,android:theme,android:icon,android:roundIcon,android:allowBackup">
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="f7cloud_messages_v2" />
@@ -88,7 +88,8 @@ internal class OfficeWebViewClient(
handler: SslErrorHandler?,
error: SslError?,
) {
if (launch.trustAllCerts) {
// trust-all TLS в WebView — только debug (см. UnsafeSsl); в release всегда штатная проверка.
if (launch.trustAllCerts && BuildConfig.DEBUG) {
handler?.proceed()
} else {
super.onReceivedSslError(view, handler, error)
@@ -71,6 +71,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import ru.forbion.f7cloud.mobile.BuildConfig
import ru.forbion.f7cloud.core.auth.AppLockStore
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.auth.AuthVerifier
@@ -172,7 +173,12 @@ fun AppScaffold(
context.applicationContext.getSharedPreferences("f7_permissions", android.content.Context.MODE_PRIVATE)
}
val logoutScope = rememberCoroutineScope()
val forceLogout = {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
session?.let { current ->
logoutScope.launch { AppPasswordRevoker.revoke(current) }
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
authStore.clear()
@@ -873,19 +879,23 @@ private fun LoginScreen(onLogin: (AuthSession) -> Unit) {
submitLogin()
},
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = trustAllCerts,
onCheckedChange = { trustAllCerts = it },
)
Text(
text = "Доверять сертификату (dev)",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
// Чекбокс trust-all показываем только в debug-сборках: в release он
// всё равно игнорируется на уровне сети/WebView (см. UnsafeSsl).
if (BuildConfig.DEBUG) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = trustAllCerts,
onCheckedChange = { trustAllCerts = it },
)
Text(
text = "Доверять сертификату (dev)",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
}
Spacer(Modifier.height(8.dp))
F7PrimaryButton(
@@ -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 {
+1
View File
@@ -14,6 +14,7 @@ android {
}
buildFeatures {
compose true
buildConfig true // BuildConfig.DEBUG — гейт trust-all TLS в WebView звонка (только debug)
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -333,7 +333,8 @@ class TalkCallActivity : ComponentActivity() {
handler: SslErrorHandler?,
error: SslError?,
) {
if (launch.trustAllCerts) {
// trust-all TLS — только debug (см. UnsafeSsl); в release штатная проверка.
if (launch.trustAllCerts && BuildConfig.DEBUG) {
handler?.proceed()
} else {
super.onReceivedSslError(view, handler, error)