Этап 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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user