diff --git a/changelog.d/9109.feature b/changelog.d/9109.feature new file mode 100644 index 00000000000..e10885147bd --- /dev/null +++ b/changelog.d/9109.feature @@ -0,0 +1 @@ +Expose service to let Element X access some internal data diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt index fa1208059a8..b5b05e3bed5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt @@ -207,6 +207,8 @@ interface CryptoService { fun getSharedWithInfo(roomId: String?, sessionId: String): MXUsersDevicesMap fun getWithHeldMegolmSession(roomId: String, sessionId: String): RoomKeyWithHeldContent? + fun exportSecrets(): Result + /** * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. @@ -238,7 +240,8 @@ interface CryptoService { deviceChanges: DeviceListResponse?, keyCounts: DeviceOneTimeKeysCountSyncResponse?, deviceUnusedFallbackKeyTypes: List?, - nextBatch: String?) + nextBatch: String?, + ) suspend fun onLiveEvent(roomId: String, event: Event, isInitialSync: Boolean, cryptoStoreAggregator: CryptoStoreAggregator?) suspend fun onStateEvent(roomId: String, event: Event, cryptoStoreAggregator: CryptoStoreAggregator?) {} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt index c998f104f48..01327cce86e 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/RustCryptoService.kt @@ -892,6 +892,12 @@ internal class RustCryptoService @Inject constructor( throw UnsupportedOperationException("Not supported by rust") } + override fun exportSecrets(): Result { + return runCatching { + olmMachine.inner().exportSecretsBundle() + } + } + override fun logDbUsageInfo() { // not available with rust // cryptoStore.logDbUsageInfo() diff --git a/vector/build.gradle b/vector/build.gradle index 5f61566f44a..72abdeb47f2 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -71,6 +71,40 @@ android { if (project.hasProperty("coverage")) { testCoverageEnabled = project.properties["coverage"] == "true" } + buildConfigField( + "java.util.List", + "ALLOWED_APP_SIGNATURES", + "java.util.Arrays.asList(\n" + + // Element + "new im.vector.app.features.importer.ApplicationFingerprint(\n" + + "\"io.element.android.x.debug\",\n" + + "java.util.Arrays.asList(\"B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E\")\n" + + "),\n" + + // Element Pro + "new im.vector.app.features.importer.ApplicationFingerprint(\n" + + "\"io.element.enterprise.debug\",\n" + + "java.util.Arrays.asList(\"B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E\")\n" + + ")\n" + + ")", + ) + } + release { + buildConfigField( + "java.util.List", + "ALLOWED_APP_SIGNATURES", + "java.util.Arrays.asList(\n" + + // Element + "new im.vector.app.features.importer.ApplicationFingerprint(\n" + + "\"io.element.android.x\",\n" + + "java.util.Arrays.asList(\"C6:DB:9B:9C:8C:BD:D6:5D:16:E8:EC:8C:8B:91:C8:31:B9:EF:C9:5C:BF:98:AE:41:F6:A9:D8:35:15:1A:7E:16\")\n" + + "),\n" + + // Element Pro + "new im.vector.app.features.importer.ApplicationFingerprint(\n" + + "\"io.element.enterprise\",\n" + + "java.util.Arrays.asList(\"FF:03:21:B0:12:BB:F2:CD:AE:4A:EF:2F:D5:47:10:05:42:29:5E:AA:12:80:2F:17:E1:6A:E0:EA:09:D6:20:F4\")\n" + + ")\n" + + ")", + ) } } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 1fae87ee2ad..b035f866fe3 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -415,6 +415,11 @@ android:foregroundServiceType="microphone" android:permission="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> + + , +) diff --git a/vector/src/main/java/im/vector/app/features/importer/ImporterService.kt b/vector/src/main/java/im/vector/app/features/importer/ImporterService.kt new file mode 100644 index 00000000000..b2b838d28f7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/importer/ImporterService.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2026 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package im.vector.app.features.importer + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import androidx.core.os.bundleOf +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.services.VectorAndroidService +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.getUser +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class ImporterService : VectorAndroidService() { + private companion object { + /** + * Command to the service to get the data. + */ + const val MSG_GET_SESSION = 1 + const val MSG_GET_AVATAR = 2 + + const val KEY_ERROR_STR = "error" + const val KEY_USER_ID_STR = "userId" + const val KEY_HOMESERVER_URL_STR = "homeserverUrl" + const val KEY_USER_DISPLAY_NAME_STR = "displayName" + const val KEY_SECRETS_STR = "secrets" + const val KEY_USER_AVATAR_PARCELABLE = "avatar" + } + + @Inject lateinit var activeSessionHolder: ActiveSessionHolder + private val signaturePermissionChecker = SignaturePermissionChecker() + + /** + * Handler of incoming messages from clients. + */ + private inner class IncomingHandler : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + Timber.w("ImporterService: handling message ${msg.what}") + val replyTo = msg.replyTo + if (replyTo == null) { + Timber.e("ImporterService: no replyTo in the message, cannot answer") + } else { + if (signaturePermissionChecker.check(msg.sendingUid, packageManager)) { + Timber.w("ImporterService: Authorized caller") + when (msg.what) { + MSG_GET_SESSION -> replyTo.sendSession() + MSG_GET_AVATAR -> { + val userId = msg.data.getString(KEY_USER_ID_STR) + replyTo.sendAvatar(userId) + } + else -> replyTo.sendError(msg.what, "Unknown command ${msg.what}") + } + } else { + Timber.w("ImporterService: Unauthorized caller") + replyTo.sendError(msg.what, "Unauthorized") + } + } + } + } + + private fun Messenger.sendSession() { + val session = activeSessionHolder.getSafeActiveSession() + val bundle = Bundle() + if (session == null) { + // Keep an empty Bundle to indicate that there is no session + } else { + bundle.putString(KEY_USER_ID_STR, session.myUserId) + bundle.putString(KEY_HOMESERVER_URL_STR, session.sessionParams.homeServerUrlBase) + session.getUser(session.myUserId)?.let { user -> + bundle.putString(KEY_USER_DISPLAY_NAME_STR, user.displayName) + } + val secret = session.cryptoService().exportSecrets() + secret.fold( + onSuccess = { secrets -> + Timber.d("ImporterService: the session has the secret, send the userId, displayName and the secrets") + bundle.putString(KEY_SECRETS_STR, secrets) + }, + onFailure = { + Timber.w(it, "ImporterService: Failed to retrieve secrets from session") + Timber.d("ImporterService: the session does not have the secret, send the userId and displayName only") + } + ) + } + sendResponse(MSG_GET_SESSION, bundle) + } + + private fun Messenger.sendAvatar(userId: String?) { + val session = activeSessionHolder.getSafeActiveSession() + val bundle = Bundle() + if (session == null) { + // Keep an empty Bundle to indicate that there is no session + sendResponse(MSG_GET_AVATAR, bundle) + } else if (userId != session.myUserId) { + Timber.w("ImporterService: The userId in the request doesn't match the session userId") + sendError(MSG_GET_AVATAR, "Invalid userId") + } else { + bundle.putString(KEY_USER_ID_STR, userId) + session.coroutineScope.launch(Dispatchers.IO) { + session.getUser(session.myUserId)?.let { user -> + user.avatarUrl + ?.let { avatarUrl -> + session.contentUrlResolver().resolveFullSize(avatarUrl) + } + ?.let { path -> + runCatching { + Glide.with(this@ImporterService) + .asBitmap() + .load(path) + .format(DecodeFormat.PREFER_ARGB_8888) + .submit() + .get() + } + .onFailure { + Timber.e(it, "ImporterService: Failed to load avatar bitmap") + } + .getOrNull() + ?.let { bitmap -> + bundle.putParcelable(KEY_USER_AVATAR_PARCELABLE, bitmap) + } + } + } + sendResponse(MSG_GET_AVATAR, bundle) + } + } + } + + private fun Messenger.sendError(what: Int, message: String) { + val bundle = bundleOf(KEY_ERROR_STR to message) + sendResponse(what, bundle) + } + + private fun Messenger.sendResponse(what: Int, bundle: Bundle) { + Timber.d("ImporterService: send response to client") + try { + val message = Message.obtain(null, what).also { + it.data = bundle + } + send(message) + } catch (e: RemoteException) { + // The client is dead. + Timber.e(e, "ImporterService: The client is dead.") + } + } + + /** + * When binding to the service, we return an interface to our messenger + * for sending messages to the service. + */ + override fun onBind(intent: Intent?): IBinder? { + Timber.w("ImporterService: onBind") + val messenger = Messenger(IncomingHandler()) + return messenger.binder + } + + override fun onUnbind(intent: Intent?): Boolean { + Timber.w("ImporterService: onUnbind") + return super.onUnbind(intent) + } +} diff --git a/vector/src/main/java/im/vector/app/features/importer/SignaturePermissionChecker.kt b/vector/src/main/java/im/vector/app/features/importer/SignaturePermissionChecker.kt new file mode 100644 index 00000000000..a5b8849be89 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/importer/SignaturePermissionChecker.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package im.vector.app.features.importer + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import im.vector.app.BuildConfig +import timber.log.Timber +import java.security.MessageDigest + +class SignaturePermissionChecker { + /** + * Check if the calling UID is allowed to access the service. + * @param sendingUid The UID of the calling process. + * @param pm The PackageManager to use to get package info. + * @return True if the calling UID is allowed, false otherwise. + */ + fun check(sendingUid: Int, pm: PackageManager): Boolean { + Timber.w("ImporterService: callingUid: $sendingUid") + val pkgs = pm.getPackagesForUid(sendingUid) ?: return false + for (pkg in pkgs) { + Timber.w("ImporterService: checking package: $pkg") + BuildConfig.ALLOWED_APP_SIGNATURES.find { it.appId == pkg }?.let { allowedApp -> + Timber.d("ImporterService: found allowed appId $pkg, checking signature") + if (isSignatureAllowed(allowedApp, pm)) { + Timber.d("ImporterService: signature allowed for package $pkg") + return true + } else { + Timber.w("ImporterService: signature not allowed for package $pkg") + } + } + } + Timber.e("ImporterService: Unauthorized attempt, denying") + return false + } + + private fun isSignatureAllowed(applicationFingerprint: ApplicationFingerprint, pm: PackageManager): Boolean { + try { + val fingerprints = getSignatureFingerprints(pm, applicationFingerprint.appId) + // The fingerprint list must exactly match the expected one + Timber.d("isSignatureAllowed: checking fingerprints $fingerprints") + return fingerprints == applicationFingerprint.fingerprints + } catch (e: Exception) { + Timber.w(e, "signature check failed for ${applicationFingerprint.appId}") + } + Timber.w("isSignatureAllowed: not allowed") + return false + } + + private fun getSignatureFingerprints(pm: PackageManager, packageName: String): List { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val pkgInfo: PackageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + if (pkgInfo.signingInfo?.hasMultipleSigners() == true) { + pkgInfo.signingInfo?.apkContentsSigners + } else { + pkgInfo.signingInfo?.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + val pkgInfo: PackageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + @Suppress("DEPRECATION") + pkgInfo.signatures + } + if (signatures.isNullOrEmpty()) { + Timber.w("ImporterService: isSignatureAllowed: no signatures found for package $packageName") + } + return signatures.orEmpty().map { sig -> + sha256Hex(sig.toByteArray()) + } + } + + private fun sha256Hex(data: ByteArray): String { + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(data) + return digest.joinToString(":") { "%02X".format(it) } + } +}