Skip to content

Commit ec79ce7

Browse files
committed
feat: implement GitHub artifact attestation verification
- Add `checkAttestations` to `DetailsRepository` to verify build integrity via GitHub's attestations API - Implement SHA-256 checksum computation for downloaded assets before installation - Introduce `AttestationStatus` to track verification states: unchecked, checking, verified, and unverified - Update `DetailsViewModel` to trigger asynchronous attestation checks post-installation - Enhance `SmartInstallButton` with a new `AttestationBadge` component to display verification status and progress - Add localized strings for "Verified build" and "Checking..." statuses - Update `DetailsState` to persist and reflect the current attestation status in the UI
1 parent e27f25e commit ec79ce7

8 files changed

Lines changed: 155 additions & 5 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@
207207
<string name="signing_key_changed_title">Signing key changed</string>
208208
<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>
209209
<string name="install_anyway">Install anyway</string>
210+
<string name="verified_build">Verified build</string>
211+
<string name="checking_attestation">Checking\u2026</string>
210212
<string name="install_version">Install %1$s</string>
211213
<string name="failed_to_open_app">Failed to open %1$s</string>
212214
<string name="failed_to_uninstall">Failed to uninstall %1$s</string>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package zed.rainxch.details.data.dto
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.json.JsonObject
5+
6+
@Serializable
7+
data class AttestationsResponse(
8+
val attestations: List<JsonObject> = emptyList(),
9+
)

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import zed.rainxch.core.data.dto.ReleaseNetwork
2020
import zed.rainxch.core.data.dto.RepoByIdNetwork
2121
import zed.rainxch.core.data.dto.RepoInfoNetwork
2222
import zed.rainxch.core.data.dto.UserProfileNetwork
23+
import zed.rainxch.details.data.dto.AttestationsResponse
2324
import zed.rainxch.core.data.mappers.toDomain
2425
import zed.rainxch.core.data.network.executeRequest
2526
import zed.rainxch.core.data.services.LocalizationManager
@@ -489,4 +490,24 @@ class DetailsRepositoryImpl(
489490
throw e
490491
}
491492
}
493+
494+
override suspend fun checkAttestations(
495+
owner: String,
496+
repo: String,
497+
sha256Digest: String,
498+
): Boolean =
499+
try {
500+
val response =
501+
httpClient
502+
.executeRequest<AttestationsResponse> {
503+
get("/repos/$owner/$repo/attestations/sha256:$sha256Digest") {
504+
header(HttpHeaders.Accept, "application/vnd.github+json")
505+
}
506+
}.getOrNull()
507+
response != null && response.attestations.isNotEmpty()
508+
} catch (e: Exception) {
509+
logger.debug("Attestation check failed for $owner/$repo: ${e.message}")
510+
false
511+
}
512+
492513
}

feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,10 @@ interface DetailsRepository {
4141
): RepoStats
4242

4343
suspend fun getUserProfile(username: String): GithubUserProfile
44+
45+
suspend fun checkAttestations(
46+
owner: String,
47+
repo: String,
48+
sha256Digest: String,
49+
): Boolean
4450
}

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
@@ -13,6 +13,7 @@ import zed.rainxch.details.presentation.model.SigningKeyWarning
1313
import zed.rainxch.details.presentation.model.DownloadStage
1414
import zed.rainxch.details.presentation.model.InstallLogItem
1515
import zed.rainxch.details.presentation.model.TranslationState
16+
import zed.rainxch.details.presentation.model.AttestationStatus
1617
import zed.rainxch.details.presentation.model.TranslationTarget
1718

1819
data class DetailsState(
@@ -64,6 +65,7 @@ data class DetailsState(
6465
val showExternalInstallerPrompt: Boolean = false,
6566
val pendingInstallFilePath: String? = null,
6667
val showUninstallConfirmation: Boolean = false,
68+
val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED,
6769
) {
6870
val filteredReleases: List<GithubRelease>
6971
get() =

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import zed.rainxch.core.domain.utils.ShareManager
4242
import zed.rainxch.details.domain.model.ReleaseCategory
4343
import zed.rainxch.details.domain.repository.DetailsRepository
4444
import zed.rainxch.details.domain.repository.TranslationRepository
45+
import zed.rainxch.details.presentation.model.AttestationStatus
4546
import zed.rainxch.details.presentation.model.DowngradeWarning
4647
import zed.rainxch.details.presentation.model.DownloadStage
4748
import zed.rainxch.details.presentation.model.InstallLogItem
@@ -61,6 +62,8 @@ import zed.rainxch.githubstore.core.presentation.res.rate_limit_exceeded
6162
import zed.rainxch.githubstore.core.presentation.res.removed_from_favourites
6263
import zed.rainxch.githubstore.core.presentation.res.translation_failed
6364
import java.io.File
65+
import java.io.FileInputStream
66+
import java.security.MessageDigest
6467
import java.util.concurrent.atomic.AtomicBoolean
6568
import kotlin.coroutines.cancellation.CancellationException
6669
import kotlin.time.Clock.System
@@ -1151,6 +1154,9 @@ class DetailsViewModel(
11511154

11521155
installer.install(filePath, ext)
11531156

1157+
// Launch attestation check asynchronously (non-blocking)
1158+
launchAttestationCheck(filePath)
1159+
11541160
if (platform == Platform.ANDROID) {
11551161
saveInstalledAppToDatabase(
11561162
assetName = assetName,
@@ -1201,6 +1207,42 @@ class DetailsViewModel(
12011207
}
12021208
}
12031209

1210+
private fun launchAttestationCheck(filePath: String) {
1211+
val repo = _state.value.repository ?: return
1212+
val owner = repo.owner.login
1213+
val repoName = repo.name
1214+
1215+
_state.update { it.copy(attestationStatus = AttestationStatus.CHECKING) }
1216+
1217+
viewModelScope.launch {
1218+
try {
1219+
val digest = computeSha256(filePath)
1220+
val verified = detailsRepository.checkAttestations(owner, repoName, digest)
1221+
_state.update {
1222+
it.copy(
1223+
attestationStatus =
1224+
if (verified) AttestationStatus.VERIFIED else AttestationStatus.UNVERIFIED,
1225+
)
1226+
}
1227+
} catch (e: Exception) {
1228+
logger.debug("Attestation check error: ${e.message}")
1229+
_state.update { it.copy(attestationStatus = AttestationStatus.UNVERIFIED) }
1230+
}
1231+
}
1232+
}
1233+
1234+
private fun computeSha256(filePath: String): String {
1235+
val digest = MessageDigest.getInstance("SHA-256")
1236+
val buffer = ByteArray(8192)
1237+
FileInputStream(File(filePath)).use { fis ->
1238+
var bytesRead: Int
1239+
while (fis.read(buffer).also { bytesRead = it } != -1) {
1240+
digest.update(buffer, 0, bytesRead)
1241+
}
1242+
}
1243+
return digest.digest().joinToString("") { "%02x".format(it) }
1244+
}
1245+
12041246
private suspend fun downloadAsset(
12051247
assetName: String,
12061248
sizeBytes: Long,
@@ -1226,6 +1268,7 @@ class DetailsViewModel(
12261268
downloadError = null,
12271269
installError = null,
12281270
downloadProgressPercent = null,
1271+
attestationStatus = AttestationStatus.UNCHECKED,
12291272
)
12301273

12311274
val existingPath = downloader.getDownloadedFilePath(assetName)

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package zed.rainxch.details.presentation.components
22

3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.fadeIn
5+
import androidx.compose.animation.fadeOut
36
import androidx.compose.foundation.background
47
import androidx.compose.foundation.clickable
58
import androidx.compose.foundation.layout.Arrangement
@@ -8,6 +11,7 @@ import androidx.compose.foundation.layout.Column
811
import androidx.compose.foundation.layout.Row
912
import androidx.compose.foundation.layout.Spacer
1013
import androidx.compose.foundation.layout.fillMaxSize
14+
import androidx.compose.foundation.layout.padding
1115
import androidx.compose.foundation.layout.height
1216
import androidx.compose.foundation.layout.size
1317
import androidx.compose.foundation.layout.width
@@ -20,7 +24,9 @@ import androidx.compose.material.icons.filled.Close
2024
import androidx.compose.material.icons.filled.Delete
2125
import androidx.compose.material.icons.filled.KeyboardArrowDown
2226
import androidx.compose.material.icons.filled.Update
27+
import androidx.compose.material.icons.filled.VerifiedUser
2328
import androidx.compose.material3.CardDefaults
29+
import androidx.compose.material3.CircularProgressIndicator
2430
import androidx.compose.material3.ElevatedCard
2531
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
2632
import androidx.compose.material3.Icon
@@ -43,13 +49,16 @@ import zed.rainxch.core.domain.model.GithubAsset
4349
import zed.rainxch.core.domain.model.GithubUser
4450
import zed.rainxch.details.presentation.DetailsAction
4551
import zed.rainxch.details.presentation.DetailsState
52+
import zed.rainxch.details.presentation.model.AttestationStatus
4653
import zed.rainxch.details.presentation.model.DownloadStage
4754
import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState
4855
import zed.rainxch.details.presentation.utils.extractArchitectureFromName
4956
import zed.rainxch.details.presentation.utils.isExactArchitectureMatch
5057
import zed.rainxch.githubstore.core.presentation.res.Res
5158
import zed.rainxch.githubstore.core.presentation.res.architecture_compatible
5259
import zed.rainxch.githubstore.core.presentation.res.cancel_download
60+
import zed.rainxch.githubstore.core.presentation.res.checking_attestation
61+
import zed.rainxch.githubstore.core.presentation.res.verified_build
5362
import zed.rainxch.githubstore.core.presentation.res.downloading
5463
import zed.rainxch.githubstore.core.presentation.res.install_latest
5564
import zed.rainxch.githubstore.core.presentation.res.install_version
@@ -97,11 +106,11 @@ fun SmartInstallButton(
97106

98107
// When same version is installed, show Open button
99108
if (isSameVersionInstalled && !isActiveDownload) {
100-
Row(
101-
modifier = modifier,
102-
verticalAlignment = Alignment.CenterVertically,
103-
horizontalArrangement = Arrangement.spacedBy(4.dp),
104-
) {
109+
Column(modifier = modifier) {
110+
Row(
111+
verticalAlignment = Alignment.CenterVertically,
112+
horizontalArrangement = Arrangement.spacedBy(4.dp),
113+
) {
105114
// Uninstall button
106115
ElevatedCard(
107116
onClick = { onAction(DetailsAction.OnRequestUninstall) },
@@ -191,6 +200,9 @@ fun SmartInstallButton(
191200
}
192201
}
193202
}
203+
}
204+
205+
AttestationBadge(attestationStatus = state.attestationStatus)
194206
}
195207
return
196208
}
@@ -535,6 +547,53 @@ fun SmartInstallButton(
535547
}
536548
}
537549

550+
@Composable
551+
private fun AttestationBadge(attestationStatus: AttestationStatus) {
552+
AnimatedVisibility(
553+
visible = attestationStatus == AttestationStatus.VERIFIED || attestationStatus == AttestationStatus.CHECKING,
554+
enter = fadeIn(),
555+
exit = fadeOut(),
556+
) {
557+
Row(
558+
modifier = Modifier.padding(top = 8.dp),
559+
verticalAlignment = Alignment.CenterVertically,
560+
horizontalArrangement = Arrangement.Center,
561+
) {
562+
when (attestationStatus) {
563+
AttestationStatus.CHECKING -> {
564+
CircularProgressIndicator(
565+
modifier = Modifier.size(14.dp),
566+
strokeWidth = 2.dp,
567+
color = MaterialTheme.colorScheme.onSurfaceVariant,
568+
)
569+
Spacer(modifier = Modifier.width(6.dp))
570+
Text(
571+
text = stringResource(Res.string.checking_attestation),
572+
style = MaterialTheme.typography.labelSmall,
573+
color = MaterialTheme.colorScheme.onSurfaceVariant,
574+
)
575+
}
576+
AttestationStatus.VERIFIED -> {
577+
Icon(
578+
imageVector = Icons.Filled.VerifiedUser,
579+
contentDescription = null,
580+
modifier = Modifier.size(14.dp),
581+
tint = MaterialTheme.colorScheme.tertiary,
582+
)
583+
Spacer(modifier = Modifier.width(4.dp))
584+
Text(
585+
text = stringResource(Res.string.verified_build),
586+
style = MaterialTheme.typography.labelSmall,
587+
color = MaterialTheme.colorScheme.tertiary,
588+
fontWeight = FontWeight.SemiBold,
589+
)
590+
}
591+
else -> {}
592+
}
593+
}
594+
}
595+
}
596+
538597
private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim()
539598

540599
private fun formatFileSize(bytes: Long): String =
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package zed.rainxch.details.presentation.model
2+
3+
enum class AttestationStatus {
4+
UNCHECKED,
5+
CHECKING,
6+
VERIFIED,
7+
UNVERIFIED,
8+
}

0 commit comments

Comments
 (0)