Skip to content
Draft
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
22 changes: 22 additions & 0 deletions vector/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ android {
if (project.hasProperty("coverage")) {
testCoverageEnabled = project.properties["coverage"] == "true"
}
buildConfigField(
"String",
"ELEMENT_X_APP_ID",
"\"io.element.android.x.debug\"",
)
buildConfigField(
"String",
"ELEMENT_X_FINGERPRINT",
"\"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\"",
)
}
release {
buildConfigField(
"String",
"ELEMENT_X_APP_ID",
"\"io.element.android.x\"",
)
buildConfigField(
"String",
"ELEMENT_X_FINGERPRINT",
"\"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\"",
)
}
}

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

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

<!-- Receivers -->

<receiver
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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.app.Service
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 dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.di.ActiveSessionHolder
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject

@AndroidEntryPoint
class ImporterService : Service() {
private companion object {
/**
* Command to the service to get the data.
*/
const val MSG_GET_DATA = 1

const val KEY_ERROR_STR = "error"
const val KEY_USER_ID_STR = "userId"
const val KEY_IS_VERIFIED_BOOLEAN = "isVerified"
const val KEY_MASTER_PRIVATE_KEY_STR = "masterPrivateKey"
const val KEY_USER_PRIVATE_KEY_STR = "userPrivateKey"
const val KEY_SELF_SIGNING_PRIVATE_KEY_STR = "selfSigningPrivateKey"
const val KEY_KEY_BACKUP_KEY_STR = "keyBackupKey"
}

@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 {
val bundle = Bundle()
if (signaturePermissionChecker.check(msg.sendingUid, packageManager)) {
Timber.w("ImporterService: Authorized caller")
when (msg.what) {
MSG_GET_DATA -> bundle.putSessionData()
else -> bundle.putString(KEY_ERROR_STR, "Unknown command ${msg.what}")
}
} else {
Timber.w("ImporterService: Unauthorized caller")
bundle.putString(KEY_ERROR_STR, "Unauthorized")
}
replyTo.sendResponse(msg.what, bundle)
}
}
}

private fun Bundle.putSessionData() {
val session = activeSessionHolder.getSafeActiveSession()
if (session == null) {
// Keep an empty Bundle to indicate that there is no session
} else {
putString(KEY_USER_ID_STR, session.myUserId)
val crossSigningService = session.cryptoService().crossSigningService()
val keysBackupService = session.cryptoService().keysBackupService()
runBlocking {
// TODO Use method exposed by the SDK (see https://github.com/matrix-org/matrix-rust-sdk/issues/6037)
putBoolean(KEY_IS_VERIFIED_BOOLEAN, crossSigningService.isCrossSigningVerified())
crossSigningService.getCrossSigningPrivateKeys()?.let { privateKeys ->
putString(KEY_MASTER_PRIVATE_KEY_STR, privateKeys.master)
putString(KEY_SELF_SIGNING_PRIVATE_KEY_STR, privateKeys.selfSigned)
putString(KEY_USER_PRIVATE_KEY_STR, privateKeys.user)
}
val keyBackupKey = keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey?.toBase64()
putString(KEY_KEY_BACKUP_KEY_STR, keyBackupKey)
}
}
}

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,81 @@
/*
* 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")
if (pkg == BuildConfig.ELEMENT_X_APP_ID && isSignatureAllowed(pkg, pm)) {
return true
}
}
Timber.e("ImporterService: Unauthorized attempt, denying")
return false
}

private fun isSignatureAllowed(packageName: String, pm: PackageManager): Boolean {
try {
val fingerprints = getSignatureFingerprints(pm, packageName)
if (fingerprints.any { fingerprint ->
Timber.d("isSignatureAllowed: checking fingerprint $fingerprint")
fingerprint == BuildConfig.ELEMENT_X_FINGERPRINT
}
) {
return true
}
} catch (e: Exception) {
Timber.w(e, "signature check failed for $packageName")
}
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