Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ffc6d334 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

feat(updates): add data-layer ownership verifiers and trusted-store policy

parent b307d4e5
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -109,6 +109,13 @@ data class Application(
     */
    val areDependentLibrariesResolved: Boolean = false,
    val dependentLibraries: List<SharedLib> = emptyList(),

    /*
     * CleanAPK signature version (e.g. "update_42") -> versionCode of the APK that was published
     * under that signature. Lets F-Droid ownership verification map an installed versionCode back
     * to the matching cleanapk signature directory without an extra round-trip.
     */
    val cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(),
) {
    val iconUrl: String?
        get() {
+24 −1
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@
package foundation.e.apps.data.cleanapk

import android.content.Context
import android.util.Base64
import foundation.e.apps.data.install.FileManager
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openpgp.PGPCompressedData
@@ -41,7 +42,7 @@ object ApkSignatureManager {
        try {
            return verifyAPKSignature(
                BufferedInputStream(FileInputStream(apkFilePath)),
                signature.byteInputStream(Charsets.UTF_8),
                signature.toPgpSignatureInputStream(),
                context.assets.open("f-droid.org-signing-key.gpg"),
                packageName
            )
@@ -108,4 +109,26 @@ object ApkSignatureManager {
            read = apkInputStream.read(buff)
        }
    }

    private fun String.toPgpSignatureInputStream(): InputStream {
        val normalized = trim()
        val bytes = if (normalized.startsWith(PGP_SIGNATURE_HEADER)) {
            normalized.toByteArray(Charsets.UTF_8)
        } else {
            Base64.decode(normalized.stripBase64Armor(), Base64.DEFAULT)
        }
        return bytes.inputStream()
    }

    private fun String.stripBase64Armor(): String {
        return lineSequence()
            .map { it.trim() }
            .filter { it.isNotEmpty() && !it.startsWith(PGP_ARMOR_CHECKSUM_PREFIX) }
            .joinToString(separator = "")
            .replace(PGP_ARMOR_CHECKSUM_SUFFIX, "")
    }

    private const val PGP_SIGNATURE_HEADER = "-----BEGIN PGP SIGNATURE-----"
    private const val PGP_ARMOR_CHECKSUM_PREFIX = "="
    private val PGP_ARMOR_CHECKSUM_SUFFIX = Regex("=[A-Za-z0-9+/]{4}$")
}
+38 −0
Original line number Diff line number Diff line
@@ -52,6 +52,8 @@ import foundation.e.apps.data.playstore.utils.GPlayHttpClient
import foundation.e.apps.data.playstore.utils.GplayHttpRequestException
import foundation.e.apps.data.playstore.utils.gplayInternalExceptionHttpStatus
import foundation.e.apps.data.preference.PlayStoreAuthStore
import foundation.e.apps.data.signing.SigningCertificateDigests
import foundation.e.apps.data.signing.normalizeSigningDigest
import foundation.e.apps.data.system.SystemInfoProvider
import foundation.e.apps.domain.auth.AuthResult
import foundation.e.apps.domain.auth.TokenRefreshHandler
@@ -206,6 +208,42 @@ class PlayStoreRepository @Inject constructor(
                .map { it.toApplication(context) }
        }

    suspend fun getSigningCertificateDigests(
        packageNames: List<String>,
    ): Map<String, SigningCertificateDigests> = withContext(Dispatchers.IO) {
        if (packageNames.isEmpty()) {
            return@withContext emptyMap()
        }

        executeWithPlayAuthRecovery(
            operationName = "app signing certificate list",
            request = { getAppDetailsHelper().getAppByPackageName(packageNames) },
        ).associate { app ->
            val digests = app.toSigningCertificateDigests()
            Timber.i(
                "Play signing digests for %s: sha256=%s, deliveryHashes=%s",
                app.packageName,
                digests.sha256Digests.size,
                digests.playStoreDeliveryHashes.size,
            )
            app.packageName to digests
        }
    }

    private fun GplayApp.toSigningCertificateDigests(): SigningCertificateDigests {
        val sha256 = certificateSetList
            .map { normalizeSigningDigest(it.sha256) }
            .filterTo(linkedSetOf()) { it.isNotBlank() }
        val delivery = buildSet {
            certificateSetList.mapTo(this) { it.certificateSet.trim() }
            certificateHashList.mapTo(this) { it.trim() }
        }.filterTo(mutableSetOf()) { it.isNotBlank() }
        return SigningCertificateDigests(
            sha256Digests = sha256,
            playStoreDeliveryHashes = delivery,
        )
    }

    override suspend fun getAppDetails(packageName: String): Application =
        withContext(Dispatchers.IO) {
            val appDetails = executeWithPlayAuthRecovery(
+216 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.updates

import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.OpenForTesting
import foundation.e.apps.data.application.ApplicationRepository
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.ApkSignatureManager
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.fdroid.FDroidRepository
import foundation.e.apps.data.handleNetworkResult
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

data class FdroidOtherStoreOwnershipResolution(
    val openSourcePackages: List<String> = emptyList(),
    val openSourceApplications: List<Application> = emptyList(),
)

@Singleton
@OpenForTesting
class FdroidOtherStoreOwnershipVerifier @Inject constructor(
    @ApplicationContext private val context: Context,
    private val appLoungePackageManager: AppLoungePackageManager,
    private val applicationRepository: ApplicationRepository,
    private val fDroidRepository: FDroidRepository,
) {
    suspend fun resolve(installedPackageNames: List<String>): FdroidOtherStoreOwnershipResolution {
        val openSourcePackages = mutableListOf<String>()
        val openSourceApplications = mutableListOf<Application>()
        val fDroidAppMetadata = getFDroidAppMetadata(installedPackageNames)

        installedPackageNames.forEach { packageName ->
            val metadata = fDroidAppMetadata[packageName]
            when {
                metadata == null -> {
                    logUnresolved(packageName, "no F-Droid metadata/signature available")
                }
                metadata.signature.isEmpty() -> {
                    logUnresolved(packageName, "F-Droid signature is empty")
                }
                matchesFdroidSignature(packageName, metadata.signature) -> {
                    logResolved(packageName, "matched F-Droid signature")
                    openSourcePackages.add(packageName)
                    openSourceApplications.add(metadata.application)
                }
                else -> {
                    logUnresolved(packageName, "F-Droid signature mismatch")
                }
            }
        }

        return FdroidOtherStoreOwnershipResolution(
            openSourcePackages = openSourcePackages,
            openSourceApplications = openSourceApplications,
        )
    }

    private suspend fun getFDroidAppMetadata(
        installedPackageNames: List<String>,
    ): Map<String, FdroidAppMetadata> = coroutineScope {
        if (installedPackageNames.isEmpty()) {
            return@coroutineScope emptyMap()
        }

        val openSourceApps = runCatching {
            applicationRepository.getApplicationDetails(
                installedPackageNames,
                Source.OPEN_SOURCE,
            ).first
        }.getOrElse { exception ->
            if (exception is CancellationException) {
                throw exception
            }
            Timber.w(exception, "Failed to fetch F-Droid metadata for other-store apps")
            emptyList()
        }

        val signatureEntries = openSourceApps
            .filterNot { it.package_name.isBlank() }
            .map { app ->
                async {
                    app.package_name to FdroidAppMetadata(
                        application = app,
                        signature = getPgpSignature(app),
                    )
                }
            }
            .awaitAll()

        signatureEntries.toMap()
    }

    private suspend fun getPgpSignature(cleanApkApplication: Application): String {
        val installedVersionSignature = calculateSignatureVersion(cleanApkApplication)
        if (installedVersionSignature.isBlank()) {
            Timber.i(
                "Skipping F-Droid signature lookup for %s: unable to resolve installed signature version",
                cleanApkApplication.package_name,
            )
            return ""
        }

        val downloadInfoResult = handleNetworkResult {
            applicationRepository
                .getOSSDownloadInfo(cleanApkApplication._id, installedVersionSignature)
                .body()?.download_data
        }

        val pgpSignature = downloadInfoResult.data?.signature ?: ""

        Timber.i(
            "Signature calculated for: %s, signature version: %s, is sig blank: %s",
            cleanApkApplication.package_name,
            installedVersionSignature,
            pgpSignature.isBlank(),
        )

        return pgpSignature
    }

    private suspend fun calculateSignatureVersion(latestCleanApkApp: Application): String {
        val packageName = latestCleanApkApp.package_name
        val latestSignatureVersion = latestCleanApkApp.latest_downloaded_version

        val installedVersionCode = appLoungePackageManager.getVersionCode(packageName)
        val installedVersionName = appLoungePackageManager.getVersionName(packageName)

        latestCleanApkApp.findCleanApkDownloadVersion(installedVersionCode)?.let { return it }

        val latestSignatureVersionNumber =
            latestSignatureVersion.substringAfter("_", "").toIntOrNull()

        val builds = handleNetworkResult {
            fDroidRepository.getBuildVersionInfo(packageName).asReversed()
        }.data

        val matchingIndex = builds?.indexOfFirst {
            it.versionCode == installedVersionCode && it.versionName == installedVersionName
        }?.takeIf { it >= 0 }

        return if (latestSignatureVersionNumber != null && matchingIndex != null) {
            "update_${latestSignatureVersionNumber - matchingIndex}"
        } else {
            ""
        }
    }

    private fun Application.findCleanApkDownloadVersion(installedVersionCode: String): String? {
        val installedVersionCodeLong = installedVersionCode.toLongOrNull() ?: return null
        return cleanApkVersionCodeByDownloadVersion.entries
            .firstOrNull { (_, versionCode) -> versionCode == installedVersionCodeLong }
            ?.key
    }

    private fun matchesFdroidSignature(packageName: String, signature: String): Boolean {
        val baseApkPath = appLoungePackageManager.getBaseApkPath(packageName)
        if (baseApkPath.isEmpty()) {
            logUnresolved(packageName, "installed base APK path missing")
            return false
        }

        return ApkSignatureManager.verifyFdroidSignature(
            context = context,
            apkFilePath = baseApkPath,
            signature = signature,
            packageName = packageName,
        )
    }

    private fun logUnresolved(packageName: String, reason: String) {
        Timber.i(
            "Other-store update owner for %s unresolved after F-Droid check: %s",
            packageName,
            reason,
        )
    }

    private fun logResolved(packageName: String, reason: String) {
        Timber.i(
            "Other-store update owner for %s resolved to %s: %s",
            packageName,
            Source.OPEN_SOURCE,
            reason,
        )
    }
}

private data class FdroidAppMetadata(
    val application: Application,
    val signature: String,
)
+68 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

package foundation.e.apps.data.updates

import foundation.e.apps.OpenForTesting
import foundation.e.apps.domain.updates.UpdateOwnership
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Session-scoped cache populated by update refreshes and consulted synchronously by
 * browsing surfaces to keep non-trusted-store UPDATE buttons source-aware.
 *
 * Cache is invalidated via package install / uninstall broadcasts.
 */
@Singleton
@OpenForTesting
class OtherStoreOwnershipCache @Inject constructor() {
    private val classifications = ConcurrentHashMap<String, UpdateOwnership>()

    fun record(resolution: OtherStoreUpdateOwnershipResolution) {
        resolution.openSourcePackages.forEach { merge(it, UpdateOwnership.OPEN_SOURCE_OWNED) }
        resolution.playStorePackages.forEach { merge(it, UpdateOwnership.PLAY_STORE_OWNED) }
        resolution.skippedPackages.forEach {
            classifications.putIfAbsent(it, UpdateOwnership.UNVERIFIED)
        }
    }

    fun put(packageName: String, ownership: UpdateOwnership) {
        classifications[packageName] = ownership
    }

    fun classify(packageName: String): UpdateOwnership? {
        return classifications[packageName]
    }

    fun invalidate() {
        classifications.clear()
    }

    private fun merge(packageName: String, incoming: UpdateOwnership) {
        classifications.merge(packageName, incoming) { current, new ->
            when {
                current == new -> current
                current == UpdateOwnership.UNVERIFIED -> new
                new == UpdateOwnership.UNVERIFIED -> current
                else -> UpdateOwnership.MULTI_SOURCE_OWNED
            }
        }
    }
}
Loading