Skip to content

Commit e27f25e

Browse files
committed
feat: implement signing fingerprint verification for auto-updates
- Add `signingFingerprint` column to the `installed_apps` table via Room migration 4 to 5 - Update `SystemPackageInfo` and `DeviceApp` domain models to include the signing fingerprint - Implement SHA-256 signing certificate extraction in `AndroidPackageMonitor` for both legacy and modern Android versions - Enhance `AutoUpdateWorker` to verify the APK's signing fingerprint against the installed version before proceeding with an update - Update `AppsRepositoryImpl` to store and propagate signing fingerprints when linking or importing apps - Refactor `ShizukuInstallerWrapper` and `AppsRepositoryImpl` for better code consistency and formatting
1 parent 95b7a94 commit e27f25e

7 files changed

Lines changed: 209 additions & 162 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package zed.rainxch.core.data.local.db.migrations
2+
3+
import androidx.room.migration.Migration
4+
import androidx.sqlite.db.SupportSQLiteDatabase
5+
6+
val MIGRATION_4_5 =
7+
object : Migration(4, 5) {
8+
override fun migrate(db: SupportSQLiteDatabase) {
9+
db.execSQL("ALTER TABLE installed_apps ADD COLUMN signingFingerprint TEXT")
10+
}
11+
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package zed.rainxch.core.data.services
33
import android.content.Context
44
import android.content.pm.ApplicationInfo
55
import android.content.pm.PackageManager
6+
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
67
import android.os.Build
78
import kotlinx.coroutines.Dispatchers
89
import kotlinx.coroutines.withContext
910
import zed.rainxch.core.domain.model.DeviceApp
1011
import zed.rainxch.core.domain.model.SystemPackageInfo
1112
import zed.rainxch.core.domain.system.PackageMonitor
13+
import java.security.MessageDigest
1214

1315
class AndroidPackageMonitor(
1416
context: Context,
@@ -20,12 +22,23 @@ class AndroidPackageMonitor(
2022
override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? =
2123
withContext(Dispatchers.IO) {
2224
runCatching {
25+
val flags =
26+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
27+
GET_SIGNING_CERTIFICATES.toLong()
28+
} else {
29+
@Suppress("DEPRECATION")
30+
PackageManager.GET_SIGNATURES.toLong()
31+
}
32+
2333
val packageInfo =
2434
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
25-
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0L))
35+
packageManager.getPackageInfo(
36+
packageName,
37+
PackageManager.PackageInfoFlags.of(flags),
38+
)
2639
} else {
2740
@Suppress("DEPRECATION")
28-
packageManager.getPackageInfo(packageName, 0)
41+
packageManager.getPackageInfo(packageName, flags.toInt())
2942
}
3043

3144
val versionCode =
@@ -36,11 +49,37 @@ class AndroidPackageMonitor(
3649
packageInfo.versionCode.toLong()
3750
}
3851

52+
val signingFingerprint: String? =
53+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
54+
val sigInfo = packageInfo.signingInfo
55+
val certs =
56+
if (sigInfo?.hasMultipleSigners() == true) {
57+
sigInfo.apkContentsSigners
58+
} else {
59+
sigInfo?.signingCertificateHistory
60+
}
61+
certs?.firstOrNull()?.toByteArray()?.let { certBytes ->
62+
MessageDigest
63+
.getInstance("SHA-256")
64+
.digest(certBytes)
65+
.joinToString(":") { "%02X".format(it) }
66+
}
67+
} else {
68+
@Suppress("DEPRECATION")
69+
packageInfo.signatures?.firstOrNull()?.toByteArray()?.let { certBytes ->
70+
MessageDigest
71+
.getInstance("SHA-256")
72+
.digest(certBytes)
73+
.joinToString(":") { "%02X".format(it) }
74+
}
75+
}
76+
3977
SystemPackageInfo(
4078
packageName = packageInfo.packageName,
4179
versionName = packageInfo.versionName ?: "unknown",
4280
versionCode = versionCode,
4381
isInstalled = true,
82+
signingFingerprint = signingFingerprint,
4483
)
4584
}.getOrNull()
4685
}
@@ -70,12 +109,10 @@ class AndroidPackageMonitor(
70109

71110
packages
72111
.filter { pkg ->
73-
// Exclude system apps (keep user-installed + updated system apps)
74112
val isSystemApp = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0
75113
val isUpdatedSystem = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
76114
!isSystemApp || isUpdatedSystem
77-
}
78-
.map { pkg ->
115+
}.map { pkg ->
79116
val versionCode =
80117
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
81118
pkg.longVersionCode
@@ -89,8 +126,8 @@ class AndroidPackageMonitor(
89126
appName = pkg.applicationInfo?.loadLabel(packageManager)?.toString() ?: pkg.packageName,
90127
versionName = pkg.versionName,
91128
versionCode = versionCode,
129+
signingFingerprint = null,
92130
)
93-
}
94-
.sortedBy { it.appName.lowercase() }
131+
}.sortedBy { it.appName.lowercase() }
95132
}
96133
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ class AutoUpdateWorker(
137137
file.delete()
138138
Logger.d { "AutoUpdateWorker: Deleted mismatched existing file for ${app.appName}" }
139139
}
140-
} catch (e: Exception) {
140+
} catch (_: Exception) {
141141
file.delete()
142142
Logger.d { "AutoUpdateWorker: Deleted unextractable existing file for ${app.appName}" }
143143
}
@@ -156,21 +156,20 @@ class AutoUpdateWorker(
156156

157157
val currentApp = installedAppsRepository.getAppByPackage(app.packageName)
158158

159-
// TOFU: Block auto-update if signing key changed
160-
if (currentApp != null &&
161-
currentApp.signingFingerprint != null &&
162-
apkInfo.signingFingerprint != null &&
163-
currentApp.signingFingerprint != apkInfo.signingFingerprint
164-
) {
165-
Logger.e {
166-
"AutoUpdateWorker: Signing key mismatch for ${app.appName}! " +
167-
"Expected: ${currentApp.signingFingerprint}, got: ${apkInfo.signingFingerprint}. " +
168-
"Skipping auto-update."
159+
if (currentApp?.signingFingerprint != null) {
160+
val expected = currentApp.signingFingerprint!!.trim().uppercase()
161+
val actual = apkInfo.signingFingerprint?.trim()?.uppercase()
162+
if (actual == null || expected != actual) {
163+
Logger.e {
164+
"AutoUpdateWorker: Signing key mismatch for ${app.appName}! " +
165+
"Expected: ${currentApp.signingFingerprint}, got: ${apkInfo.signingFingerprint}. " +
166+
"Skipping auto-update."
167+
}
168+
throw IllegalStateException(
169+
"Signing fingerprint verification failed for ${app.appName}, blocking auto-update",
170+
)
169171
}
170-
throw IllegalStateException("Signing key changed for ${app.appName}, blocking auto-update")
171-
}
172172

173-
if (currentApp != null) {
174173
installedAppsRepository.updateApp(
175174
currentApp.copy(
176175
isPendingInstall = true,
@@ -181,17 +180,17 @@ class AutoUpdateWorker(
181180
latestVersionCode = apkInfo.versionCode,
182181
),
183182
)
184-
}
185183

186-
Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" }
187-
try {
188-
installer.install(filePath, ext)
189-
} catch (e: Exception) {
190-
installedAppsRepository.updatePendingStatus(app.packageName, false)
191-
throw e
192-
}
184+
Logger.d { "AutoUpdateWorker: Installing ${app.appName} via Shizuku" }
185+
try {
186+
installer.install(filePath, ext)
187+
} catch (e: Exception) {
188+
installedAppsRepository.updatePendingStatus(app.packageName, false)
189+
throw e
190+
}
193191

194-
Logger.d { "AutoUpdateWorker: Install command completed for ${app.appName}, waiting for system confirmation via broadcast" }
192+
Logger.d { "AutoUpdateWorker: Install command completed for ${app.appName}, waiting for system confirmation via broadcast" }
193+
}
195194
}
196195

197196
private fun createForegroundInfo(

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerWrapper.kt

Lines changed: 34 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
66
import kotlinx.coroutines.launch
77
import kotlinx.coroutines.runBlocking
88
import kotlinx.coroutines.withContext
9+
import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus
910
import zed.rainxch.core.domain.model.GithubAsset
1011
import zed.rainxch.core.domain.model.InstallerType
1112
import zed.rainxch.core.domain.model.SystemArchitecture
@@ -26,9 +27,8 @@ import zed.rainxch.core.domain.system.InstallerInfoExtractor
2627
class ShizukuInstallerWrapper(
2728
private val androidInstaller: Installer,
2829
private val shizukuServiceManager: ShizukuServiceManager,
29-
private val themesRepository: ThemesRepository
30+
private val themesRepository: ThemesRepository,
3031
) : Installer {
31-
3232
companion object {
3333
private const val TAG = "ShizukuInstaller"
3434
}
@@ -53,50 +53,39 @@ class ShizukuInstallerWrapper(
5353
}
5454
}
5555

56-
// ==================== Delegated methods (always go to AndroidInstaller) ====================
57-
58-
override suspend fun isSupported(extOrMime: String): Boolean =
59-
androidInstaller.isSupported(extOrMime)
56+
override suspend fun isSupported(extOrMime: String): Boolean = androidInstaller.isSupported(extOrMime)
6057

61-
override fun isAssetInstallable(assetName: String): Boolean =
62-
androidInstaller.isAssetInstallable(assetName)
58+
override fun isAssetInstallable(assetName: String): Boolean = androidInstaller.isAssetInstallable(assetName)
6359

64-
override fun choosePrimaryAsset(assets: List<GithubAsset>): GithubAsset? =
65-
androidInstaller.choosePrimaryAsset(assets)
60+
override fun choosePrimaryAsset(assets: List<GithubAsset>): GithubAsset? = androidInstaller.choosePrimaryAsset(assets)
6661

67-
override fun detectSystemArchitecture(): SystemArchitecture =
68-
androidInstaller.detectSystemArchitecture()
62+
override fun detectSystemArchitecture(): SystemArchitecture = androidInstaller.detectSystemArchitecture()
6963

70-
override fun isObtainiumInstalled(): Boolean =
71-
androidInstaller.isObtainiumInstalled()
64+
override fun isObtainiumInstalled(): Boolean = androidInstaller.isObtainiumInstalled()
7265

7366
override fun openInObtainium(
7467
repoOwner: String,
7568
repoName: String,
76-
onOpenInstaller: () -> Unit
69+
onOpenInstaller: () -> Unit,
7770
) = androidInstaller.openInObtainium(repoOwner, repoName, onOpenInstaller)
7871

79-
override fun isAppManagerInstalled(): Boolean =
80-
androidInstaller.isAppManagerInstalled()
72+
override fun isAppManagerInstalled(): Boolean = androidInstaller.isAppManagerInstalled()
8173

8274
override fun openInAppManager(
8375
filePath: String,
84-
onOpenInstaller: () -> Unit
76+
onOpenInstaller: () -> Unit,
8577
) = androidInstaller.openInAppManager(filePath, onOpenInstaller)
8678

87-
override fun getApkInfoExtractor(): InstallerInfoExtractor =
88-
androidInstaller.getApkInfoExtractor()
89-
90-
override fun openApp(packageName: String): Boolean =
91-
androidInstaller.openApp(packageName)
79+
override fun getApkInfoExtractor(): InstallerInfoExtractor = androidInstaller.getApkInfoExtractor()
9280

93-
override fun openWithExternalInstaller(filePath: String) =
94-
androidInstaller.openWithExternalInstaller(filePath)
81+
override fun openApp(packageName: String): Boolean = androidInstaller.openApp(packageName)
9582

96-
// ==================== Overridden methods (may use Shizuku) ====================
83+
override fun openWithExternalInstaller(filePath: String) = androidInstaller.openWithExternalInstaller(filePath)
9784

9885
override suspend fun ensurePermissionsOrThrow(extOrMime: String) {
99-
Logger.d(TAG) { "ensurePermissionsOrThrow() — extOrMime=$extOrMime, cachedType=$cachedInstallerType, status=${shizukuServiceManager.status.value}" }
86+
Logger.d(TAG) {
87+
"ensurePermissionsOrThrow() — extOrMime=$extOrMime, cachedType=$cachedInstallerType, status=${shizukuServiceManager.status.value}"
88+
}
10089
if (shouldUseShizuku()) {
10190
Logger.d(TAG) { "Shizuku active — skipping unknown sources permission check" }
10291
return
@@ -105,7 +94,10 @@ class ShizukuInstallerWrapper(
10594
androidInstaller.ensurePermissionsOrThrow(extOrMime)
10695
}
10796

108-
override suspend fun install(filePath: String, extOrMime: String) {
97+
override suspend fun install(
98+
filePath: String,
99+
extOrMime: String,
100+
) {
109101
Logger.d(TAG) { "install() called — filePath=$filePath, extOrMime=$extOrMime" }
110102
Logger.d(TAG) { "cachedInstallerType=$cachedInstallerType, shizukuStatus=${shizukuServiceManager.status.value}" }
111103

@@ -114,18 +106,19 @@ class ShizukuInstallerWrapper(
114106
try {
115107
val service = shizukuServiceManager.getService()
116108
if (service != null) {
117-
// Run the blocking AIDL call on IO dispatcher to avoid ANR
118-
val result = withContext(Dispatchers.IO) {
119-
val file = java.io.File(filePath)
120-
val pfd = android.os.ParcelFileDescriptor.open(
121-
file,
122-
android.os.ParcelFileDescriptor.MODE_READ_ONLY
123-
)
124-
pfd.use {
125-
Logger.d(TAG) { "Got Shizuku service, calling installPackage($filePath, size=${file.length()})..." }
126-
service.installPackage(it, file.length())
109+
val result =
110+
withContext(Dispatchers.IO) {
111+
val file = java.io.File(filePath)
112+
val pfd =
113+
android.os.ParcelFileDescriptor.open(
114+
file,
115+
android.os.ParcelFileDescriptor.MODE_READ_ONLY,
116+
)
117+
pfd.use {
118+
Logger.d(TAG) { "Got Shizuku service, calling installPackage($filePath, size=${file.length()})..." }
119+
service.installPackage(it, file.length())
120+
}
127121
}
128-
}
129122
Logger.d(TAG) { "Shizuku installPackage() returned: $result" }
130123
if (result == 0) {
131124
Logger.d(TAG) { "Shizuku install SUCCEEDED for: $filePath" }
@@ -142,7 +135,6 @@ class ShizukuInstallerWrapper(
142135
Logger.d(TAG) { "Not using Shizuku (enabled=${isShizukuEnabled()}, status=${shizukuServiceManager.status.value})" }
143136
}
144137

145-
// Fallback: ensure permissions then use standard installer
146138
Logger.d(TAG) { "Using standard AndroidInstaller for: $filePath" }
147139
androidInstaller.ensurePermissionsOrThrow(extOrMime)
148140
androidInstaller.install(filePath, extOrMime)
@@ -154,10 +146,8 @@ class ShizukuInstallerWrapper(
154146

155147
if (isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY) {
156148
Logger.d(TAG) { "Attempting Shizuku uninstall..." }
157-
// Fire on background thread — callers don't await result for standard uninstall either
158149
Thread {
159150
try {
160-
// Bind/get service on this background thread (getService is suspend)
161151
val service = runBlocking { shizukuServiceManager.getService() }
162152
if (service != null) {
163153
Logger.d(TAG) { "Got service, calling uninstallPackage($packageName)..." }
@@ -185,13 +175,7 @@ class ShizukuInstallerWrapper(
185175
androidInstaller.uninstall(packageName)
186176
}
187177

188-
// ==================== Internal helpers ====================
189-
190-
private suspend fun shouldUseShizuku(): Boolean {
191-
return isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY
192-
}
178+
private suspend fun shouldUseShizuku(): Boolean = isShizukuEnabled() && shizukuServiceManager.status.value == ShizukuStatus.READY
193179

194-
private fun isShizukuEnabled(): Boolean {
195-
return cachedInstallerType == InstallerType.SHIZUKU
196-
}
180+
private fun isShizukuEnabled(): Boolean = cachedInstallerType == InstallerType.SHIZUKU
197181
}

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ data class DeviceApp(
55
val appName: String,
66
val versionName: String?,
77
val versionCode: Long,
8+
val signingFingerprint: String?,
89
)

core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/SystemPackageInfo.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ data class SystemPackageInfo(
55
val versionName: String,
66
val versionCode: Long,
77
val isInstalled: Boolean,
8+
val signingFingerprint: String?,
89
)

0 commit comments

Comments
 (0)