From 51fd1bf936a03d7471d374d8d5ea00e93d89f43d Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Tue, 7 Jul 2026 19:51:23 +0000 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=200=20=D0=BF.3:=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C-=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC=D1=83=D0=BC=20(trus?= =?UTF-8?q?t-all=20off=20=D0=B2=20release,=20=D1=88=D0=B8=D1=84=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8F,=20allowBackup,=20=D1=80=D0=B5=D0=B2=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + INTEGRATION.md | 1 + app/src/main/AndroidManifest.xml | 4 +- .../f7cloud/mobile/OfficeWebViewClient.kt | 3 +- .../forbion/f7cloud/mobile/ui/AppScaffold.kt | 36 ++++++--- .../f7cloud/core/auth/AppPasswordRevoker.kt | 31 ++++++++ .../ru/forbion/f7cloud/core/auth/AuthStore.kt | 21 ++++- .../f7cloud/core/auth/KeystoreCrypto.kt | 79 +++++++++++++++++++ core/network/build.gradle | 3 + .../forbion/f7cloud/core/network/UnsafeSsl.kt | 4 + feature/talk/build.gradle | 1 + .../f7cloud/feature/talk/TalkCallActivity.kt | 3 +- 12 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordRevoker.kt create mode 100644 core/auth/src/main/java/ru/forbion/f7cloud/core/auth/KeystoreCrypto.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ee245..656fba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — только оптимизация; сборка на билд-сервере ещё не прогонялась diff --git a/INTEGRATION.md b/INTEGRATION.md index 71993d3..522ca45 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -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 | контакты | diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 65dadc4..eb6ab6a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,13 +22,13 @@ + tools:replace="android:label,android:theme,android:icon,android:roundIcon,android:allowBackup"> diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt index c758139..0ecad27 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/OfficeWebViewClient.kt @@ -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) 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 9774b99..4a72f50 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 @@ -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( diff --git a/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordRevoker.kt b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordRevoker.kt new file mode 100644 index 0000000..4bb7550 --- /dev/null +++ b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/AppPasswordRevoker.kt @@ -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) + } +} 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 b50661e..385c88c 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 @@ -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() { 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 new file mode 100644 index 0000000..f7a1cc9 --- /dev/null +++ b/core/auth/src/main/java/ru/forbion/f7cloud/core/auth/KeystoreCrypto.kt @@ -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() + } +} diff --git a/core/network/build.gradle b/core/network/build.gradle index 7203ec7..06d47e6 100644 --- a/core/network/build.gradle +++ b/core/network/build.gradle @@ -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 diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/UnsafeSsl.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/UnsafeSsl.kt index 4196254..4a18c8e 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/UnsafeSsl.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/UnsafeSsl.kt @@ -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( object : X509TrustManager { diff --git a/feature/talk/build.gradle b/feature/talk/build.gradle index 5658bf2..0ee8b00 100644 --- a/feature/talk/build.gradle +++ b/feature/talk/build.gradle @@ -14,6 +14,7 @@ android { } buildFeatures { compose true + buildConfig true // BuildConfig.DEBUG — гейт trust-all TLS в WebView звонка (только debug) } compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkCallActivity.kt b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkCallActivity.kt index 3b9c08f..d121f53 100644 --- a/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkCallActivity.kt +++ b/feature/talk/src/main/java/ru/forbion/f7cloud/feature/talk/TalkCallActivity.kt @@ -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)