UpdateManifest.kt

14.3 KB · raw

1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONArray5import org.json.JSONObject6import java.io.File7import java.io.InputStream8import java.security.MessageDigest910/**11 * Kepr's generic release manifest, as returned by12 * `GET https://kepr.uk/api/<repo>/releases/latest`, flattened to the single13 * platform this app can install.14 *15 * ```json16 * { "version": "2.3.68",17 *   "notes": "...",18 *   "platforms": { "android-arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } }19 * ```20 */21data class ReleaseManifest(22    val version: String,23    val url: String,24    val checksum: String,25    /** Absent — not empty, not "null" — when the release carries no notes. */26    val notes: String?,27) {28    fun toJson(): String = JSONObject().apply {29        put("version", version)30        put("url", url)31        put("checksum", checksum)32        if (notes != null) put("notes", notes)33    }.toString()3435    companion object {36        /**37         * The cached manifest is re-read straight into the download path, so it38         * has to clear the same strict-SemVer bar [parseReleaseManifest] applies39         * to a fresh fetch — otherwise a rewritten cache entry reaches40         * `downloadAndVerify`, whose filename safety argument rests on that41         * validation having happened. A malformed cache entry parses to null,42         * which callers already treat as "no update known".43         */44        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {45            val o = JSONObject(json)46            ReleaseManifest(47                version = o.getString("version").also(::validateSemver),48                url = o.getString("url"),49                checksum = o.getString("checksum"),50                notes = o.optTextOrNull("notes"),51            )52        }.getOrNull()53    }54}5556class ManifestFormatException(message: String) : Exception(message)5758/** Thrown when the APK on disk is not the package/version the updater was told to install. */59class ApkIdentityException(message: String) : Exception(message)6061/** Thrown when the download would exceed [MAX_APK_BYTES] or advertise a larger Content-Length. */62class DownloadSizeException(message: String) : Exception(message)6364/**65 * Kepr platform keys for the two APK splits Peach publishes. `android-x86`66 * carries the x86_64 APK; the shorter public key matches Kepr's platform67 * vocabulary while Android's ABI name remains `x86_64` inside the archive.68 */69const val ANDROID_PLATFORM_KEY = "android-arm64"70const val ANDROID_X86_PLATFORM_KEY = "android-x86"7172fun androidPlatformKey(supportedAbis: Array<String>): String? = when {73    "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY74    "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY75    else -> null76}7778/**79 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves80 * headroom for growth without letting a hostile manifest exhaust cache or disk.81 */82const val MAX_APK_BYTES = 50L * 1024 * 10248384/**85 * Strict SemVer core: exactly three non-empty, non-leading-zero decimal86 * components, each small enough to fit in an Int. Anything else (NaN segments,87 * extra components, negatives, overflow) is refused — the manifest's version88 * is later used to derive the cache filename, so a permissive parser here would89 * let `..` or `/` reach the filesystem.90 */91private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")9293internal fun validateSemver(version: String) {94    if (!STRICT_SEMVER.matches(version)) {95        throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")96    }97    version.split('.').forEach { seg ->98        // STRICT_SEMVER already excludes non-digits and leading zeros, so this99        // overflow check is the only thing left standing between "9999999999"100        // and a silently-wrapped compareVersions result.101        if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {102            throw ManifestFormatException("version '$version' has an out-of-range segment")103        }104    }105}106107/**108 * True when a parsed APK archive is the package and version this updater109 * belongs to. Pure: takes the values PackageManager already extracted so the110 * Android side stays at the call site and this stays unit-testable.111 *112 * The package must be an exact match. In particular, a production build must113 * never hand a separately-installable `.debug` package to Android's installer.114 * Debug builds still work in their own process because the caller supplies115 * that process's exact `Context.packageName` as [expectedPackage].116 */117fun isExpectedApk(118    packageName: String?,119    versionName: String?,120    versionCode: Long,121    expectedPackage: String,122    expectedVersionName: String,123    currentVersionCode: Long,124): Boolean {125    if (packageName != expectedPackage) return false126    if (versionName != expectedVersionName) return false127    // Kepr's release manifest carries the semantic version but not the Android128    // versionCode.  The enforceable contract is therefore exact SemVer plus a129    // strictly monotonic code, which blocks equal-version replays and downgrades130    // while still allowing Gradle to choose the next code independently.131    return versionCode > currentVersionCode132}133134/**135 * Select the PackageInfo version-code field without loading the API-28-only136 * property on Oreo.  Keeping the branch as a pure helper lets JVM tests cover137 * both sides even though the unit-test runner is not a real API-26 device.138 */139internal fun packageVersionCodeForSdk(140    sdkInt: Int,141    legacyVersionCode: Int,142    modernVersionCode: Long,143): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()144145/**146 * Select the signing-info flag set for the minimum supported API.  The flag147 * values are supplied by the Android caller so this helper remains a plain148 * integer decision that can be tested without an SDK emulator.149 */150internal fun packageSigningInfoFlagsForSdk(151    sdkInt: Int,152    legacyFlags: Int,153    modernFlags: Int,154): Int = if (sdkInt >= 28) modernFlags else legacyFlags155156/**157 * Check the direction of an APK signing-key rotation against the installed158 * package's current signer set.159 *160 * A normal update has the same current signer set. A forward v3 rotation has161 * the installed current signer in the archive's proof-of-rotation history and162 * a new current signer. The reverse direction (an old key with a higher163 * versionCode) is deliberately rejected: an archive must prove that it is a164 * descendant of the key currently protecting the installed app, not merely165 * share any historical certificate with it. Equal signer-set cardinality also166 * prevents a multi-signer package from being reduced to a subset.167 *168 * API 26/27 callers pass an empty [archiveHistory], making this an exact169 * signature-set comparison on platforms without [android.content.pm.SigningInfo].170 */171internal fun isSigningLineageCompatible(172    archiveCurrent: Set<String>,173    archiveHistory: Set<String>,174    installedCurrent: Set<String>,175): Boolean {176    if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false177    if (archiveCurrent.size != installedCurrent.size) return false178    return installedCurrent.all { it in archiveCurrent || it in archiveHistory }179}180181/**182 * Parse a Kepr release manifest.183 *184 * Two contract wrinkles are handled deliberately:185 * - The notes key is `notes` canonically, but the published Guidelines still show186 *   the retired `release_notes`. Both are accepted, `notes` winning.187 * - `optString` on a JSON null yields the literal string "null" in org.json, which188 *   would render as changelog text. [optTextOrNull] rejects that class of value189 *   outright rather than trusting the caller to notice.190 *191 * Security note (H-08): the canonical platform key is required and the192 * sole-artifact fallback is gone. A Kepr build that only published, say,193 * `android-x86` would have been accepted before and pointed an Android phone at194 * the wrong binary; now it is rejected outright. A signed release-manifest195 * channel is the real fix — that is an architectural change and is out of scope196 * for H-08.197 */198fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {199    val root = runCatching { JSONObject(body) }200        .getOrElse { throw ManifestFormatException("manifest is not JSON") }201202    val version = root.optTextOrNull("version")203        ?: throw ManifestFormatException("manifest has no version")204    validateSemver(version)205206    val platforms = root.optJSONObject("platforms")207        ?: throw ManifestFormatException("manifest has no platforms")208209    val platform = platforms.optJSONObject(platformKey)210        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")211212    val url = platform.optTextOrNull("url")213        ?: throw ManifestFormatException("artifact has no url")214    val checksum = parseArtifactChecksum(platform)215        ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")216217    return ReleaseManifest(218        version = version,219        url = url,220        checksum = checksum,221        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),222    )223}224225/**226 * Extract a `blake3:<hex>` (preferred) or `sha256:<hex>` checksum from a Kepr227 * artifact object. Three shapes are accepted:228 * - Modern: separate `blake3` and/or `sha256` top-level fields on the artifact.229 *   blake3 wins when both are present.230 * - Legacy: a single `checksum` field shaped as `<algorithm>:<64 hex>`. Older231 *   Kepr builds used this and the existing tests still pin it.232 * The string is normalized to `<algorithm>:<64 lowercase hex>` so233 * [parseChecksum] downstream can validate it.234 */235private fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {236    val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)237    if (blake3 != null) return "blake3:$blake3"238    val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)239    if (sha256 != null) return "sha256:$sha256"240    return artifact.optTextOrNull("checksum")241}242243private fun isHex64(value: String): Boolean =244    value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }245246/** Non-blank string value, or null — never JSON null surfaced as the text "null". */247internal fun JSONObject.optTextOrNull(key: String): String? {248    if (!has(key) || isNull(key)) return null249    return optString(key).trim().takeIf { it.isNotEmpty() }250}251252/** One published release from Kepr's `releases/history` endpoint. */253data class ReleaseNote(val version: String, val notes: String?)254255/**256 * Parse Kepr's `GET /api/<repo>/releases/history` response: a JSON array of257 * `{version, notes}` objects, newest first. Notes stay null when a release258 * shipped none, so the UI decides what to show for it.259 */260fun parseReleaseHistory(json: String): List<ReleaseNote> {261    val arr = JSONArray(json)262    return (0 until arr.length()).map { i ->263        val obj = arr.getJSONObject(i)264        ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes"))265    }266}267268/* ─────────────────────────────  version compare  ───────────────────────────── */269270/**271 * Strip everything that isn't the numeric release, so a debug build272 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was273 * built from rather than as an unrelated string.274 */275fun normalizeVersion(version: String): String =276    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')277278/**279 * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong.280 *281 * Tolerant on purpose, and only safely so: both untrusted entry points282 * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run283 * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the284 * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a285 * malformed local version string into a crash on every update check.286 */287fun compareVersions(a: String, b: String): Int {288    val left = normalizeVersion(a).split('.')289    val right = normalizeVersion(b).split('.')290    for (i in 0 until maxOf(left.size, right.size)) {291        val x = left.getOrNull(i)?.toIntOrNull() ?: 0292        val y = right.getOrNull(i)?.toIntOrNull() ?: 0293        if (x != y) return x.compareTo(y)294    }295    return 0296}297298fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0299300/* ──────────────────────────────────  checksum  ─────────────────────────────── */301302data class ExpectedChecksum(val algorithm: String, val hex: String)303304class ChecksumMismatchException(message: String) : Exception(message)305306private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")307308/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */309fun parseChecksum(raw: String): ExpectedChecksum? {310    val separator = raw.indexOf(':')311    if (separator <= 0) return null312    val algorithm = raw.substring(0, separator).trim().lowercase()313    val hex = raw.substring(separator + 1).trim().lowercase()314    if (algorithm !in SUPPORTED_CHECKSUMS) return null315    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null316    return ExpectedChecksum(algorithm, hex)317}318319fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {320    "blake3" -> {321        val digest = Blake3Digest(32)322        val buffer = ByteArray(64 * 1024)323        while (true) {324            val read = input.read(buffer)325            if (read <= 0) break326            digest.update(buffer, 0, read)327        }328        val out = ByteArray(digest.digestSize)329        digest.doFinal(out, 0)330        out.toHex()331    }332    "sha256" -> {333        val digest = MessageDigest.getInstance("SHA-256")334        val buffer = ByteArray(64 * 1024)335        while (true) {336            val read = input.read(buffer)337            if (read <= 0) break338            digest.update(buffer, 0, read)339        }340        digest.digest().toHex()341    }342    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")343}344345fun hashFile(file: File, algorithm: String): String =346    file.inputStream().use { hashStream(it, algorithm) }347348private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }