Skip to content

Commit 95b7a94

Browse files
committed
feat: implement signing key verification and warning dialogs
- Add `SigningKeyWarning` model and update `DetailsState` to handle certificate mismatch scenarios - Implement a warning dialog in `DetailsRoot` that displays expected vs. actual fingerprints when a signing key change is detected - Add `OnDismissSigningKeyWarning` and `OnOverrideSigningKeyWarning` actions to `DetailsViewModel` to allow users to bypass the warning - Update `AutoUpdateWorker` to block automatic updates if the APK signing key has changed - Enhance `DetailsViewModel` to extract and verify APK package info/fingerprints before installation - Include the app's own SHA-256 fingerprint when saving its installation state in `GithubStoreApp` - Bump database version to 5 and add migration `MIGRATION_4_5` - Add localized string resources for signing key change titles, messages, and override actions - Minor cleanup of string concatenation logic in `AppsViewModel`
1 parent 19d1635 commit 95b7a94

11 files changed

Lines changed: 188 additions & 23 deletions

File tree

composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ class GithubStoreApp : Application() {
144144
isPendingInstall = false,
145145
installedVersionName = versionName,
146146
installedVersionCode = versionCode,
147+
signingFingerprint = SELF_SHA256_FINGERPRINT,
147148
)
148149

149150
repo.saveInstalledApp(selfApp)
@@ -156,6 +157,9 @@ class GithubStoreApp : Application() {
156157

157158
companion object {
158159
private const val SELF_REPO_ID = 1101281251L
160+
private const val SELF_SHA256_FINGERPRINT =
161+
@Suppress("ktlint:standard:max-line-length")
162+
"B7:F2:8E:19:8E:48:C1:93:B0:38:C6:5D:92:DD:F7:BC:07:7B:0D:B5:9E:BC:9B:25:0A:6D:AC:48:C1:18:03:CA"
159163
private const val SELF_REPO_OWNER = "OpenHub-Store"
160164
private const val SELF_REPO_NAME = "GitHub-Store"
161165
private const val SELF_AVATAR_URL =

core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
66
import zed.rainxch.core.data.local.db.migrations.MIGRATION_1_2
77
import zed.rainxch.core.data.local.db.migrations.MIGRATION_2_3
88
import zed.rainxch.core.data.local.db.migrations.MIGRATION_3_4
9+
import zed.rainxch.core.data.local.db.migrations.MIGRATION_4_5
910

1011
fun initDatabase(context: Context): AppDatabase {
1112
val appContext = context.applicationContext
@@ -19,5 +20,6 @@ fun initDatabase(context: Context): AppDatabase {
1920
MIGRATION_1_2,
2021
MIGRATION_2_3,
2122
MIGRATION_3_4,
23+
MIGRATION_4_5,
2224
).build()
2325
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,21 @@ class AutoUpdateWorker(
155155
?: throw IllegalStateException("Failed to extract APK info for ${app.appName}")
156156

157157
val currentApp = installedAppsRepository.getAppByPackage(app.packageName)
158+
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."
169+
}
170+
throw IllegalStateException("Signing key changed for ${app.appName}, blocking auto-update")
171+
}
172+
158173
if (currentApp != null) {
159174
installedAppsRepository.updateApp(
160175
currentApp.copy(

core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity
2121
StarredRepositoryEntity::class,
2222
CacheEntryEntity::class,
2323
],
24-
version = 4,
24+
version = 5,
2525
exportSchema = true,
2626
)
2727
abstract class AppDatabase : RoomDatabase() {

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@
204204
<string name="downgrade_requires_uninstall">Downgrade requires uninstall</string>
205205
<string name="downgrade_warning_message">Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost.</string>
206206
<string name="uninstall_first">Uninstall first</string>
207+
<string name="signing_key_changed_title">Signing key changed</string>
208+
<string name="signing_key_changed_message">The signing certificate for this app has changed since it was first installed.\n\nThis could mean the developer rotated their signing key, or the binary may have been tampered with.\n\nExpected: %1$s\nReceived: %2$s</string>
209+
<string name="install_anyway">Install anyway</string>
207210
<string name="install_version">Install %1$s</string>
208211
<string name="failed_to_open_app">Failed to open %1$s</string>
209212
<string name="failed_to_uninstall">Failed to uninstall %1$s</string>

feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -856,12 +856,8 @@ class AppsViewModel(
856856
_events.send(
857857
AppsEvent.ShowSuccess(
858858
"Imported ${result.imported} apps" +
859-
if (result.skipped > 0) {
860-
", ${result.skipped} skipped"
861-
} else {
862-
"" +
863-
if (result.failed > 0) ", ${result.failed} failed" else ""
864-
},
859+
(if (result.skipped > 0) ", ${result.skipped} skipped" else "") +
860+
(if (result.failed > 0) ", ${result.failed} failed" else ""),
865861
),
866862
)
867863
} catch (e: Exception) {

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ sealed interface DetailsAction {
1313

1414
data object OnDismissDowngradeWarning : DetailsAction
1515

16+
data object OnDismissSigningKeyWarning : DetailsAction
17+
18+
data object OnOverrideSigningKeyWarning : DetailsAction
19+
1620
data object UninstallApp : DetailsAction
1721
data object OnRequestUninstall : DetailsAction
1822
data object OnDismissUninstallConfirmation : DetailsAction

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ import zed.rainxch.githubstore.core.presentation.res.Res
7676
import zed.rainxch.githubstore.core.presentation.res.add_to_favourites
7777
import zed.rainxch.githubstore.core.presentation.res.cancel
7878
import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_message
79+
import zed.rainxch.githubstore.core.presentation.res.install_anyway
80+
import zed.rainxch.githubstore.core.presentation.res.signing_key_changed_message
81+
import zed.rainxch.githubstore.core.presentation.res.signing_key_changed_title
7982
import zed.rainxch.githubstore.core.presentation.res.confirm_uninstall_title
8083
import zed.rainxch.githubstore.core.presentation.res.dismiss
8184
import zed.rainxch.githubstore.core.presentation.res.downgrade_requires_uninstall
@@ -192,6 +195,53 @@ fun DetailsRoot(
192195
)
193196
}
194197

198+
// Signing key changed warning dialog
199+
state.signingKeyWarning?.let { warning ->
200+
AlertDialog(
201+
onDismissRequest = {
202+
viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning)
203+
},
204+
title = {
205+
Text(
206+
text = stringResource(Res.string.signing_key_changed_title),
207+
)
208+
},
209+
text = {
210+
Text(
211+
text =
212+
stringResource(
213+
Res.string.signing_key_changed_message,
214+
warning.expectedFingerprint.take(19),
215+
warning.actualFingerprint.take(19),
216+
),
217+
)
218+
},
219+
confirmButton = {
220+
TextButton(
221+
onClick = {
222+
viewModel.onAction(DetailsAction.OnOverrideSigningKeyWarning)
223+
},
224+
) {
225+
Text(
226+
text = stringResource(Res.string.install_anyway),
227+
color = MaterialTheme.colorScheme.error,
228+
)
229+
}
230+
},
231+
dismissButton = {
232+
TextButton(
233+
onClick = {
234+
viewModel.onAction(DetailsAction.OnDismissSigningKeyWarning)
235+
},
236+
) {
237+
Text(
238+
text = stringResource(Res.string.cancel),
239+
)
240+
}
241+
},
242+
)
243+
}
244+
195245
// Uninstall confirmation dialog
196246
if (state.showUninstallConfirmation) {
197247
val appName = state.installedApp?.appName ?: ""

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import zed.rainxch.core.domain.model.SystemArchitecture
99
import zed.rainxch.details.domain.model.ReleaseCategory
1010
import zed.rainxch.details.domain.model.RepoStats
1111
import zed.rainxch.details.presentation.model.DowngradeWarning
12+
import zed.rainxch.details.presentation.model.SigningKeyWarning
1213
import zed.rainxch.details.presentation.model.DownloadStage
1314
import zed.rainxch.details.presentation.model.InstallLogItem
1415
import zed.rainxch.details.presentation.model.TranslationState
@@ -59,6 +60,7 @@ data class DetailsState(
5960
val deviceLanguageCode: String = "en",
6061
val isComingFromUpdate: Boolean = false,
6162
val downgradeWarning: DowngradeWarning? = null,
63+
val signingKeyWarning: SigningKeyWarning? = null,
6264
val showExternalInstallerPrompt: Boolean = false,
6365
val pendingInstallFilePath: String? = null,
6466
val showUninstallConfirmation: Boolean = false,

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import zed.rainxch.details.presentation.model.DownloadStage
4747
import zed.rainxch.details.presentation.model.InstallLogItem
4848
import zed.rainxch.details.presentation.model.LogResult
4949
import zed.rainxch.details.presentation.model.LogResult.Error
50+
import zed.rainxch.details.presentation.model.SigningKeyWarning
5051
import zed.rainxch.details.presentation.model.SupportedLanguages
5152
import zed.rainxch.details.presentation.model.TranslationState
5253
import zed.rainxch.githubstore.core.presentation.res.Res
@@ -361,6 +362,55 @@ class DetailsViewModel(
361362
}
362363
}
363364

365+
DetailsAction.OnDismissSigningKeyWarning -> {
366+
_state.update {
367+
it.copy(
368+
signingKeyWarning = null,
369+
downloadStage = DownloadStage.IDLE,
370+
)
371+
}
372+
currentAssetName = null
373+
}
374+
375+
DetailsAction.OnOverrideSigningKeyWarning -> {
376+
val warning = _state.value.signingKeyWarning ?: return
377+
_state.update { it.copy(signingKeyWarning = null) }
378+
viewModelScope.launch {
379+
try {
380+
val ext = warning.pendingAssetName.substringAfterLast('.', "").lowercase()
381+
installer.install(warning.pendingFilePath, ext)
382+
383+
if (platform == Platform.ANDROID) {
384+
saveInstalledAppToDatabase(
385+
assetName = warning.pendingAssetName,
386+
assetUrl = warning.pendingDownloadUrl,
387+
assetSize = warning.pendingSizeBytes,
388+
releaseTag = warning.pendingReleaseTag,
389+
isUpdate = warning.pendingIsUpdate,
390+
filePath = warning.pendingFilePath,
391+
)
392+
}
393+
394+
_state.value = _state.value.copy(downloadStage = DownloadStage.IDLE)
395+
currentAssetName = null
396+
appendLog(
397+
assetName = warning.pendingAssetName,
398+
size = warning.pendingSizeBytes,
399+
tag = warning.pendingReleaseTag,
400+
result = if (warning.pendingIsUpdate) LogResult.Updated else LogResult.Installed,
401+
)
402+
} catch (t: Throwable) {
403+
logger.error("Install after override failed: ${t.message}")
404+
_state.value =
405+
_state.value.copy(
406+
downloadStage = DownloadStage.IDLE,
407+
installError = t.message,
408+
)
409+
currentAssetName = null
410+
}
411+
}
412+
}
413+
364414
DetailsAction.InstallPrimary -> {
365415
val primary = _state.value.primaryAsset
366416
val release = _state.value.selectedRelease
@@ -1012,6 +1062,8 @@ class DetailsViewModel(
10121062
sizeBytes = sizeBytes,
10131063
releaseTag = releaseTag,
10141064
)
1065+
} catch (e: kotlinx.coroutines.CancellationException) {
1066+
throw e
10151067
} catch (t: Throwable) {
10161068
logger.error("Install failed: ${t.message}")
10171069
t.printStackTrace()
@@ -1040,39 +1092,63 @@ class DetailsViewModel(
10401092
releaseTag: String,
10411093
) {
10421094
_state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING)
1043-
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
1044-
if (apkInfo != null) {
1045-
ApkPackageInfo(
1046-
packageName = apkInfo.packageName,
1047-
appName = apkInfo.appName,
1048-
versionName = apkInfo.versionName,
1049-
versionCode = apkInfo.versionCode,
1050-
signingFingerprint = apkInfo.signingFingerprint,
1051-
)
1052-
} else {
1053-
logger.error("Failed to extract APK info for $assetName")
1054-
}
10551095

1056-
apkInfo?.let {
1096+
val ext = assetName.substringAfterLast('.', "").lowercase()
1097+
val isApk = ext == "apk"
1098+
1099+
if (isApk) {
1100+
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
1101+
if (apkInfo == null) {
1102+
logger.error("Failed to extract APK info for $assetName")
1103+
_state.value = _state.value.copy(
1104+
downloadStage = DownloadStage.IDLE,
1105+
installError = "Failed to verify APK package info",
1106+
)
1107+
currentAssetName = null
1108+
appendLog(
1109+
assetName = assetName,
1110+
size = sizeBytes,
1111+
tag = releaseTag,
1112+
result = Error("Failed to extract APK info"),
1113+
)
1114+
return
1115+
}
1116+
10571117
val result =
10581118
checkFingerprints(
10591119
apkPackageInfo = apkInfo,
10601120
)
10611121

10621122
result
10631123
.onFailure {
1124+
val existingApp =
1125+
installedAppsRepository.getAppByPackage(apkInfo.packageName)
1126+
_state.update { state ->
1127+
state.copy(
1128+
signingKeyWarning =
1129+
SigningKeyWarning(
1130+
packageName = apkInfo.packageName,
1131+
expectedFingerprint = existingApp?.signingFingerprint ?: "",
1132+
actualFingerprint = apkInfo.signingFingerprint ?: "",
1133+
pendingDownloadUrl = downloadUrl,
1134+
pendingAssetName = assetName,
1135+
pendingSizeBytes = sizeBytes,
1136+
pendingReleaseTag = releaseTag,
1137+
pendingIsUpdate = isUpdate,
1138+
pendingFilePath = filePath,
1139+
),
1140+
)
1141+
}
10641142
appendLog(
10651143
assetName = assetName,
10661144
size = sizeBytes,
10671145
tag = releaseTag,
1068-
result = Error("Fingerprints does not match!"),
1146+
result = Error("Signing key changed"),
10691147
)
1070-
10711148
return
10721149
}
10731150
}
10741151

1075-
val ext = assetName.substringAfterLast('.', "").lowercase()
10761152
installer.install(filePath, ext)
10771153

10781154
if (platform == Platform.ANDROID) {

0 commit comments

Comments
 (0)