Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9109.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expose service to let Element X access some internal data
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ interface CryptoService {
fun getSharedWithInfo(roomId: String?, sessionId: String): MXUsersDevicesMap<Int>
fun getWithHeldMegolmSession(roomId: String, sessionId: String): RoomKeyWithHeldContent?

fun exportSecrets(): Result<String>

/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
Expand Down Expand Up @@ -238,7 +240,8 @@ interface CryptoService {
deviceChanges: DeviceListResponse?,
keyCounts: DeviceOneTimeKeysCountSyncResponse?,
deviceUnusedFallbackKeyTypes: List<String>?,
nextBatch: String?)
nextBatch: String?,
)

suspend fun onLiveEvent(roomId: String, event: Event, isInitialSync: Boolean, cryptoStoreAggregator: CryptoStoreAggregator?)
suspend fun onStateEvent(roomId: String, event: Event, cryptoStoreAggregator: CryptoStoreAggregator?) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,12 @@ internal class RustCryptoService @Inject constructor(
throw UnsupportedOperationException("Not supported by rust")
}

override fun exportSecrets(): Result<String> {
return runCatching {
olmMachine.inner().exportSecretsBundle()
}
}

override fun logDbUsageInfo() {
// not available with rust
// cryptoStore.logDbUsageInfo()
Expand Down
34 changes: 34 additions & 0 deletions vector/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ android {
if (project.hasProperty("coverage")) {
testCoverageEnabled = project.properties["coverage"] == "true"
}
buildConfigField(
"java.util.List<im.vector.app.features.importer.ApplicationFingerprint>",
"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<im.vector.app.features.importer.ApplicationFingerprint>",
"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" +
")",
)
}
}

Expand Down
5 changes: 5 additions & 0 deletions vector/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,11 @@
android:foregroundServiceType="microphone"
android:permission="android.permission.FOREGROUND_SERVICE_MICROPHONE" />

<service
android:name=".features.importer.ImporterService"
android:exported="true"
tools:ignore="ExportedService" />

<!-- Receivers -->

<receiver
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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

data class ApplicationFingerprint(
val appId: String,
val fingerprints: List<String>,
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<String> {
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) }
}
}
Loading