diff --git a/app/src/main/kotlin/com/wire/android/datastore/EncryptionManager.kt b/app/src/main/kotlin/com/wire/android/datastore/EncryptionManager.kt index b317d98cee8..b7be6b2e2a8 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/EncryptionManager.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/EncryptionManager.kt @@ -20,6 +20,8 @@ package com.wire.android.datastore import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 +import com.wire.android.util.crypto.AppCryptoServiceInfo +import com.wire.android.util.crypto.appCryptoServiceInfo import java.io.UnsupportedEncodingException import java.nio.charset.Charset import java.security.InvalidKeyException @@ -38,11 +40,33 @@ object EncryptionManager { private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM private const val PADDING = KeyProperties.ENCRYPTION_PADDING_NONE private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + private const val ANDROID_KEY_STORE = "AndroidKeyStore" - private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + private val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) } private val cipher = Cipher.getInstance(TRANSFORMATION) private val charset = Charset.defaultCharset() + /** + * Which providers serve DataStore crypto, for the security providers debug screen. + * + * Lives here, next to the call sites, so it shares their algorithm constants: change a constant and + * this follows automatically instead of quietly reporting the old one. + * + * The key generator is only resolved, never initialised with a [KeyGenParameterSpec] or asked for a + * key, so nothing is written to the Android keystore. + */ + fun cryptoServices(): List = listOfNotNull( + appCryptoServiceInfo("DataStore keystore", "KeyStore.getInstance(\"$ANDROID_KEY_STORE\")") { + KeyStore.getInstance(ANDROID_KEY_STORE).run { type to provider } + }, + appCryptoServiceInfo("DataStore key generation", "KeyGenerator.getInstance(\"$ALGORITHM\")") { + KeyGenerator.getInstance(ALGORITHM).run { algorithm to provider } + }, + appCryptoServiceInfo("DataStore cipher", "Cipher.getInstance(\"$TRANSFORMATION\")") { + Cipher.getInstance(TRANSFORMATION).run { algorithm to provider } + }, + ) + private fun getKey(keyAlias: String): SecretKey { val existingKey = keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry return existingKey?.secretKey ?: createKey(keyAlias) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt index 0338c54fac4..1c3d4c2c2f4 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/DebugModule.kt @@ -28,6 +28,7 @@ import com.wire.kalium.logic.feature.debug.DebugScope import com.wire.kalium.logic.feature.debug.DisableEventProcessingUseCase import com.wire.kalium.logic.feature.debug.GetConversationCryptoStatsUseCase import com.wire.kalium.logic.feature.debug.GetConversationEpochFromCCUseCase +import com.wire.kalium.logic.feature.debug.GetCryptoServiceReportUseCase import com.wire.kalium.logic.feature.debug.GetDebugE2EICertificateExpirationUseCase import com.wire.kalium.logic.feature.debug.GetFeatureConfigUseCase import com.wire.kalium.logic.feature.debug.GetSqlCipherVersionUseCase @@ -76,6 +77,10 @@ class DebugModule { @Provides fun provideGetSqlCipherVersionUseCase(debugScope: DebugScope): GetSqlCipherVersionUseCase = debugScope.getSqlCipherVersion + @Provides + fun provideGetCryptoServiceReportUseCase(debugScope: DebugScope): GetCryptoServiceReportUseCase = + debugScope.getCryptoServiceReport + @Provides fun provideGetDebugE2EICertificateExpirationUseCase(debugScope: DebugScope): GetDebugE2EICertificateExpirationUseCase = debugScope.getDebugE2EICertificateExpiration diff --git a/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt index bceacb747f5..8d75853ce9b 100644 --- a/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/e2ei/OAuthUseCase.kt @@ -26,6 +26,8 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.contract.ActivityResultContracts import com.wire.android.appLogger +import com.wire.android.util.crypto.AppCryptoServiceInfo +import com.wire.android.util.crypto.appCryptoServiceInfo import com.wire.android.util.deeplink.DeepLinkProcessor import com.wire.android.util.findParameterValue import com.wire.android.util.removeQueryParams @@ -210,6 +212,22 @@ class OAuthUseCase( const val CODE_VERIFIER_CHALLENGE_METHOD = "S256" const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" val MESSAGE_DIGEST = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + /** + * Which providers serve PKCE crypto, for the security providers debug screen. + * + * Lives here, next to the call sites, so it shares their algorithm constants: change a constant + * and this follows automatically instead of quietly reporting the old one. + */ + fun cryptoServices(): List = listOfNotNull( + appCryptoServiceInfo("OAuth PKCE verifier", "SecureRandom()") { + SecureRandom().run { algorithm to provider } + }, + appCryptoServiceInfo("OAuth PKCE challenge", "MessageDigest.getInstance(\"$MESSAGE_DIGEST_ALGORITHM\")") { + MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM).run { algorithm to provider } + }, + ) + const val ENCODING = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP val URL_AUTH_REDIRECT: Uri = Uri.Builder().scheme(DeepLinkProcessor.DEEP_LINK_SCHEME) .authority(DeepLinkProcessor.E2EI_DEEPLINK_HOST) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/AppPathsProvider.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/AppPathsProvider.kt index 0820013bb4f..60104f7a114 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/AppPathsProvider.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/AppPathsProvider.kt @@ -18,25 +18,27 @@ package com.wire.android.ui.debug.securityproviders import android.content.Context -import androidx.annotation.StringRes import com.wire.android.R +import com.wire.android.di.ApplicationContext +import com.wire.android.di.CurrentAccount import com.wire.kalium.logic.data.user.UserId +import dev.zacsweers.metro.Inject import java.io.File -class AppPathsProvider( - private val context: Context, - private val currentAccount: UserId, +class AppPathsProvider @Inject constructor( + @ApplicationContext private val context: Context, + @CurrentAccount private val currentAccount: UserId, ) { - operator fun invoke(): List = with(context) { + operator fun invoke(): List = with(context) { val accountSuffix = "${currentAccount.domain}/${currentAccount.value}" listOf( - AppPathEntry(R.string.debug_settings_app_path_assets, "$filesDir/$accountSuffix"), - AppPathEntry(R.string.debug_settings_app_path_cache, "$cacheDir/$accountSuffix"), - AppPathEntry(R.string.debug_settings_app_path_files_dir, filesDir.absolutePath), - AppPathEntry(R.string.debug_settings_app_path_cache_dir, cacheDir.absolutePath), - AppPathEntry(R.string.debug_settings_app_path_databases_dir, getDatabasePath(DATABASE_NAME_PROBE).parent.orEmpty()), - AppPathEntry(R.string.debug_settings_app_path_no_backup_dir, noBackupFilesDir.absolutePath), - AppPathEntry(R.string.debug_settings_app_path_external_files_dir, getExternalFilesDir(null)?.absolutePath.orEmpty()), + LabelledValue(R.string.debug_settings_app_path_assets, "$filesDir/$accountSuffix"), + LabelledValue(R.string.debug_settings_app_path_cache, "$cacheDir/$accountSuffix"), + LabelledValue(R.string.debug_settings_app_path_files_dir, filesDir.absolutePath), + LabelledValue(R.string.debug_settings_app_path_cache_dir, cacheDir.absolutePath), + LabelledValue(R.string.debug_settings_app_path_databases_dir, getDatabasePath(DATABASE_NAME_PROBE).parent.orEmpty()), + LabelledValue(R.string.debug_settings_app_path_no_backup_dir, noBackupFilesDir.absolutePath), + LabelledValue(R.string.debug_settings_app_path_external_files_dir, getExternalFilesDir(null)?.absolutePath.orEmpty()), ) } @@ -115,8 +117,3 @@ sealed interface SqliteHeaderStatus { @Suppress("MagicNumber") private fun ByteArray.toHexString(): String = joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } - -data class AppPathEntry( - @StringRes val labelRes: Int, - val path: String, -) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/CryptoServiceListItem.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/CryptoServiceListItem.kt new file mode 100644 index 00000000000..275af716981 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/CryptoServiceListItem.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.debug.securityproviders + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.rowitem.RowItem +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun CryptoServiceListItem( + row: CryptoServiceRow, + modifier: Modifier = Modifier, +) { + RowItem( + modifier = modifier + .fillMaxWidth() + .padding(dimensions().spacing16x), + clickable = Clickable(enabled = false), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = row.label, + style = typography().body02, + color = colorsScheme().onBackground, + ) + Text( + text = row.lookup, + style = typography().label01, + color = colorsScheme().secondaryText, + ) + Text( + text = stringResource( + R.string.debug_settings_crypto_service_resolved, + row.algorithm, + row.providerName, + row.providerVersion, + ), + style = typography().body02, + color = colorsScheme().onBackground, + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewCryptoServiceListItem() = WireTheme { + CryptoServiceListItem( + row = CryptoServiceRow( + label = "Asset AES-256 key", + lookup = "KeyGenerator.getInstance(\"AES\")", + algorithm = "AES", + providerName = "AndroidOpenSSL", + providerVersion = "1.0", + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/LabelledValue.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/LabelledValue.kt new file mode 100644 index 00000000000..630e633080d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/LabelledValue.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.debug.securityproviders + +import androidx.annotation.StringRes + +/** A debug row whose label comes from resources and whose value is resolved at runtime. */ +data class LabelledValue( + @StringRes val labelRes: Int, + val value: String, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt index 7f20607446a..ff6c732a6b8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt @@ -75,7 +75,15 @@ internal fun SecurityProvidersRouteScreen( ) { SectionHeader(stringResource(R.string.debug_settings_app_paths)) state.appPaths.forEach { entry -> - SettingsItem(title = stringResource(entry.labelRes), text = entry.path) + SettingsItem(title = stringResource(entry.labelRes), text = entry.value) + } + + SectionHeader(stringResource(R.string.debug_settings_entropy_sources)) + if (state.cryptoServices?.isEmpty() == true) { + SettingsItem(text = stringResource(R.string.debug_settings_crypto_services_empty)) + } + state.cryptoServices?.forEach { row -> + CryptoServiceListItem(row) } state.network?.let { network -> diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt index 4af66e0b33a..465aa6d7a5a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt @@ -20,7 +20,11 @@ package com.wire.android.ui.debug.securityproviders import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger +import com.wire.android.util.crypto.AppCryptoServiceInfo +import com.wire.android.util.crypto.appCryptoServices import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.feature.debug.CryptoServiceUsage +import com.wire.kalium.logic.feature.debug.GetCryptoServiceReportUseCase import com.wire.kalium.logic.feature.debug.GetSqlCipherVersionUseCase import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.network.NetworkStateObserver @@ -37,6 +41,7 @@ import kotlinx.coroutines.withContext @OptIn(DebugKaliumApi::class) class SecurityProvidersViewModel @Inject constructor( private val appPathsProvider: AppPathsProvider, + private val getCryptoServiceReport: GetCryptoServiceReportUseCase, private val networkDiagnosticsProvider: NetworkDiagnosticsProvider, private val networkStateObserver: NetworkStateObserver, private val selfServerConfig: SelfServerConfigUseCase, @@ -55,9 +60,13 @@ class SecurityProvidersViewModel @Inject constructor( userDatabase = appPathsProvider.userDatabaseSecurityStatus(), ) } + val appServices = withContext(dispatchers.io()) { appCryptoServices() } + val cryptoServices = getCryptoServiceReport().map(CryptoServiceUsage::toRow) + + appServices.map(AppCryptoServiceInfo::toRow) _state.update { current -> current.copy( appPaths = appPathsProvider(), + cryptoServices = cryptoServices, databaseSecurity = databaseSecurity ) } @@ -84,10 +93,36 @@ class SecurityProvidersViewModel @Inject constructor( } } +private fun AppCryptoServiceInfo.toRow() = CryptoServiceRow( + label = name, + lookup = lookup, + algorithm = algorithm, + providerName = providerName, + providerVersion = providerVersion, +) + +@OptIn(DebugKaliumApi::class) +private fun CryptoServiceUsage.toRow() = CryptoServiceRow( + label = name, + lookup = lookup, + algorithm = algorithm, + providerName = providerName, + providerVersion = providerVersion, +) + +/** One cryptographic lookup, and the provider that serves it on this device. */ +data class CryptoServiceRow( + val label: String, + val lookup: String, + val algorithm: String, + val providerName: String, + val providerVersion: String, +) + data class SecurityProvidersViewState( - val appPaths: List = emptyList(), + val appPaths: List = emptyList(), val network: NetworkDiagnostics? = null, - val providers: List? = null, + val cryptoServices: List? = null, val databaseSecurity: DatabaseSecurityInfo? = null, ) diff --git a/app/src/main/kotlin/com/wire/android/util/crypto/AppCryptoServiceInfo.kt b/app/src/main/kotlin/com/wire/android/util/crypto/AppCryptoServiceInfo.kt new file mode 100644 index 00000000000..cbd24a35ead --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/crypto/AppCryptoServiceInfo.kt @@ -0,0 +1,33 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util.crypto + +/** + * Which security provider serves one cryptographic lookup, read off the instance the platform returned. + * + * @param name what the lookup is for, e.g. `DataStore cipher`. + * @param lookup the lookup performed, as written in the source, e.g. `KeyGenerator.getInstance("AES")`. + * @param algorithm the algorithm the resolved instance reports, e.g. `AES/GCM/NoPadding`. + */ +data class AppCryptoServiceInfo( + val name: String, + val lookup: String, + val algorithm: String, + val providerName: String, + val providerVersion: String, +) diff --git a/app/src/main/kotlin/com/wire/android/util/crypto/AppCryptoServices.kt b/app/src/main/kotlin/com/wire/android/util/crypto/AppCryptoServices.kt new file mode 100644 index 00000000000..2600b2c2c69 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/crypto/AppCryptoServices.kt @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util.crypto + +import com.wire.android.datastore.EncryptionManager +import com.wire.android.feature.e2ei.OAuthUseCase +import java.security.Provider + +/** + * Which security provider serves each cryptographic lookup in the app module. + * + * Mirrors kalium's `cryptoServices()` for the lookups the app makes directly, which kalium cannot see. + * Which implementation backs an algorithm is decided at runtime by walking the installed security + * providers, so it varies per device, per OEM and per OS version. Each contributing function performs the + * same lookups its call sites perform, from the same file and with the same algorithm constants, and reads + * the provider off what comes back. + * + * Only for the security providers debug screen. Performs lookups and nothing else: no key is persisted and + * no crypto state is mutated. + * + * `SecureRandom` lookups can block while the platform gathers entropy, so call this off the main thread. + */ +fun appCryptoServices(): List = + EncryptionManager.cryptoServices() + OAuthUseCase.cryptoServices() + +/** + * Performs [resolve] and reads the algorithm and provider off the instance it returned, so what is reported + * is what the platform actually handed back. + * + * Null when the lookup fails: a debug screen must not bring down the caller over a missing algorithm. + * + * @param name what the lookup is for, e.g. `DataStore cipher`. + * @param lookup the lookup performed, as written in the source. Interpolate the same constants [resolve] + * uses, so this cannot describe a lookup the call sites do not make. + * @param resolve the JCA lookup, returning its result's `algorithm` and `provider`. + */ +fun appCryptoServiceInfo(name: String, lookup: String, resolve: () -> Pair): AppCryptoServiceInfo? = + runCatching(resolve).getOrNull()?.let { (algorithm, provider) -> + AppCryptoServiceInfo( + name = name, + lookup = lookup, + algorithm = algorithm, + providerName = provider.name, + providerVersion = provider.versionString(), + ) + } + +/** + * `Provider.getVersionStr()` needs API 28 and `Provider.getVersion()` is deprecated, so read the version + * out of the provider's own property map, where it is registered under this key. + */ +private fun Provider.versionString(): String = getProperty(PROVIDER_VERSION_PROPERTY).orEmpty() + +private const val PROVIDER_VERSION_PROPERTY = "Provider.id version" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d6d20e7bea..25a5145a518 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1799,6 +1799,9 @@ In group conversations, the group admin can overwrite this setting. Databases dir No backup dir External files dir + Entropy sources + %1$s \u2014 %2$s %3$s + No crypto service could be resolved %1$d entries Network VPN