Enforce monotonic household releases and staged APK integrity
This commit is contained in:
parent
c78a8dfa8d
commit
383802611c
10 changed files with 351 additions and 45 deletions
|
|
@ -38,6 +38,7 @@ class HouseholdUpdateControllerTest {
|
|||
private var truncated = false
|
||||
private var chunked = false
|
||||
private var unsupported = false
|
||||
private var manifestSigner = UpdateChannel.SIGNER
|
||||
private var redirect = false
|
||||
private var redirectTarget: String? = null
|
||||
private val requests = mutableListOf<Map<String, List<String>>>()
|
||||
|
|
@ -54,7 +55,7 @@ class HouseholdUpdateControllerTest {
|
|||
override fun inspect(file: File) = archive
|
||||
override fun hasInstallPermission() = permission
|
||||
override fun openPermissionSettings() = settingsAvailable
|
||||
override fun install(file: File, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||
}
|
||||
private val preferences = object : UpdatePreferences {
|
||||
override val autoCheck = MutableStateFlow(true)
|
||||
|
|
@ -104,7 +105,7 @@ class HouseholdUpdateControllerTest {
|
|||
@After fun stop() { server.stop(0) }
|
||||
|
||||
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
|
||||
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER,
|
||||
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner,
|
||||
listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(),
|
||||
if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23)))
|
||||
|
||||
|
|
@ -163,6 +164,15 @@ class HouseholdUpdateControllerTest {
|
|||
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
|
||||
}
|
||||
|
||||
@Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest {
|
||||
manifestSigner = "b".repeat(64)
|
||||
val controller = controller(this)
|
||||
controller.check(true); advanceUntilIdle()
|
||||
assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind)
|
||||
assertFalse(File(directory, "verified.apk").exists())
|
||||
assertEquals(0, platform.installs)
|
||||
}
|
||||
|
||||
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
|
||||
for (badChecksum in listOf(true, false)) {
|
||||
badHash = badChecksum; truncated = !badChecksum
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.MessageDigest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** A replacement or incomplete stream must never reach the session commit boundary. */
|
||||
class UpdateSessionStreamTest {
|
||||
private val original = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(),
|
||||
MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23)
|
||||
|
||||
@Test fun sameLengthReplacementAfterVerificationIsRejected() {
|
||||
val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() }
|
||||
expectMismatch(replaced)
|
||||
}
|
||||
|
||||
@Test fun truncatedAndOversizedSessionStreamsAreRejected() {
|
||||
expectMismatch(original.copyOf(original.size - 1))
|
||||
expectMismatch(original + byteArrayOf(1))
|
||||
}
|
||||
|
||||
private fun expectMismatch(bytes: ByteArray) {
|
||||
try {
|
||||
bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) }
|
||||
fail("Changed installation bytes were accepted")
|
||||
} catch (error: UpdateException) {
|
||||
assertEquals(UpdateFailure.ChecksumMismatch, error.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import android.os.Build
|
|||
import android.provider.Settings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.MessageDigest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -47,7 +49,7 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
|||
return try { context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (_: RuntimeException) { false }
|
||||
}
|
||||
|
||||
override fun install(file: File, outcome: (InstallOutcome) -> Unit) {
|
||||
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) {
|
||||
if (context.noBackupFilesDir.usableSpace < file.length()) {
|
||||
outcome(InstallOutcome.InsufficientStorage)
|
||||
return
|
||||
|
|
@ -55,7 +57,7 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
|||
val installer = manager.packageInstaller
|
||||
val parameters = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
setAppPackageName(context.packageName)
|
||||
setSize(file.length())
|
||||
setSize(asset.bytes)
|
||||
if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)
|
||||
}
|
||||
var sessionId: Int? = null
|
||||
|
|
@ -63,8 +65,8 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
|||
sessionId = installer.createSession(parameters)
|
||||
check(sessions.edit().putInt("id", sessionId).commit())
|
||||
installer.openSession(sessionId).use { session ->
|
||||
session.openWrite("Wolfskin.apk", 0, file.length()).use { output ->
|
||||
file.inputStream().use { it.copyTo(output) }
|
||||
session.openWrite("Wolfskin.apk", 0, asset.bytes).use { output ->
|
||||
file.inputStream().use { copyVerifiedUpdate(it, output, asset) }
|
||||
session.fsync(output)
|
||||
}
|
||||
val intent = Intent(context, UpdateInstallReceiver::class.java).setAction("${context.packageName}.UPDATE_RESULT")
|
||||
|
|
@ -72,8 +74,9 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
|||
val result = PendingIntent.getBroadcast(context, sessionId, intent, flags)
|
||||
session.commit(result.intentSender)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (error: Exception) {
|
||||
sessionId?.let { runCatching { installer.abandonSession(it) } }
|
||||
if (error is UpdateException) throw error
|
||||
outcome(InstallOutcome.Unavailable)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,3 +91,21 @@ class AndroidUpdatePlatform @Inject constructor(@param:ApplicationContext privat
|
|||
} catch (_: RuntimeException) { false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Check the bytes actually staged into Android, not a prior read of the private path. */
|
||||
internal fun copyVerifiedUpdate(input: InputStream, output: OutputStream, asset: UpdateAsset) {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var count = 0L
|
||||
while (true) {
|
||||
val length = input.read(buffer)
|
||||
if (length < 0) break
|
||||
count += length
|
||||
if (count > asset.bytes) throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation")
|
||||
digest.update(buffer, 0, length)
|
||||
output.write(buffer, 0, length)
|
||||
}
|
||||
if (count != asset.bytes || digest.digest().toHexString() != asset.sha256) {
|
||||
throw UpdateException(UpdateFailure.ChecksumMismatch, "APK changed while staging installation")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,17 +143,20 @@ class HouseholdUpdateController(
|
|||
if (state.value == UpdateUiState.HandingOff) return@runOperation
|
||||
val manifest = ready ?: return@runOperation
|
||||
retryOperation = { install() }
|
||||
try { store.verify(store.apk, manifest, manifest.selectAsset(abis, sdk)) } catch (error: Exception) {
|
||||
val asset = manifest.selectAsset(abis, sdk)
|
||||
try {
|
||||
store.verify(store.apk, manifest, asset)
|
||||
if (!platform.hasInstallPermission()) {
|
||||
mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller
|
||||
return@runOperation
|
||||
}
|
||||
mutableState.value = UpdateUiState.HandingOff
|
||||
platform.install(store.apk, asset, ::handleInstallOutcome)
|
||||
} catch (error: Exception) {
|
||||
store.clear(); ready = null
|
||||
retryOperation = { if (candidate != null) download() else check(true) }
|
||||
throw error
|
||||
}
|
||||
if (!platform.hasInstallPermission()) {
|
||||
mutableState.value = if (platform.openPermissionSettings()) UpdateUiState.PermissionNeeded else UpdateUiState.NoInstaller
|
||||
return@runOperation
|
||||
}
|
||||
mutableState.value = UpdateUiState.HandingOff
|
||||
platform.install(store.apk, ::handleInstallOutcome)
|
||||
}
|
||||
|
||||
fun handleInstallOutcome(outcome: InstallOutcome) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ interface UpdatePlatform {
|
|||
fun inspect(file: File): ApkIdentity?
|
||||
fun hasInstallPermission(): Boolean
|
||||
fun openPermissionSettings(): Boolean
|
||||
fun install(file: File, outcome: (InstallOutcome) -> Unit)
|
||||
fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit)
|
||||
}
|
||||
|
||||
/** Installer results do not imply the running process has been replaced. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue