UpdateManifest.kt

13.6 KB · raw

1package com.peachpasswords.updates23import org.bouncycastle.crypto.digests.Blake3Digest4import org.json.JSONObject5import java.io.File6import java.io.InputStream7import java.security.MessageDigest89/**10 * Kepr's generic release manifest, as returned by11 * `GET https://kepr.uk/api/<repo>/releases/latest`, flattened to the single12 * platform this app can install.13 *14 * ```json15 * { "version": "2.3.68",16 *   "notes": "...",17 *   "platforms": { "android-arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } }18 * ```19 */20data class ReleaseManifest(21    val version: String,22    val url: String,23    val checksum: String,24    /** Absent — not empty, not "null" — when the release carries no notes. */25    val notes: String?,26) {27    fun toJson(): String = JSONObject().apply {28        put("version", version)29        put("url", url)30        put("checksum", checksum)31        if (notes != null) put("notes", notes)32    }.toString()3334    companion object {35        /**36         * The cached manifest is re-read straight into the download path, so it37         * has to clear the same strict-SemVer bar [parseReleaseManifest] applies38         * to a fresh fetch — otherwise a rewritten cache entry reaches39         * `downloadAndVerify`, whose filename safety argument rests on that40         * validation having happened. A malformed cache entry parses to null,41         * which callers already treat as "no update known".42         */43        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {44            val o = JSONObject(json)45            ReleaseManifest(46                version = o.getString("version").also(::validateSemver),47                url = o.getString("url"),48                checksum = o.getString("checksum"),49                notes = o.optTextOrNull("notes"),50            )51        }.getOrNull()52    }53}5455class ManifestFormatException(message: String) : Exception(message)5657/** Thrown when the APK on disk is not the package/version the updater was told to install. */58class ApkIdentityException(message: String) : Exception(message)5960/** Thrown when the download would exceed [MAX_APK_BYTES] or advertise a larger Content-Length. */61class DownloadSizeException(message: String) : Exception(message)6263/**64 * Kepr platform keys for the two APK splits Peach publishes. `android-x86`65 * carries the x86_64 APK; the shorter public key matches Kepr's platform66 * vocabulary while Android's ABI name remains `x86_64` inside the archive.67 */68const val ANDROID_PLATFORM_KEY = "android-arm64"69const val ANDROID_X86_PLATFORM_KEY = "android-x86"7071fun androidPlatformKey(supportedAbis: Array<String>): String? = when {72    "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY73    "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY74    else -> null75}7677/**78 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves79 * headroom for growth without letting a hostile manifest exhaust cache or disk.80 */81const val MAX_APK_BYTES = 50L * 1024 * 10248283/**84 * Strict SemVer core: exactly three non-empty, non-leading-zero decimal85 * components, each small enough to fit in an Int. Anything else (NaN segments,86 * extra components, negatives, overflow) is refused — the manifest's version87 * is later used to derive the cache filename, so a permissive parser here would88 * let `..` or `/` reach the filesystem.89 */90private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")9192internal fun validateSemver(version: String) {93    if (!STRICT_SEMVER.matches(version)) {94        throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")95    }96    version.split('.').forEach { seg ->97        // STRICT_SEMVER already excludes non-digits and leading zeros, so this98        // overflow check is the only thing left standing between "9999999999"99        // and a silently-wrapped compareVersions result.100        if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {101            throw ManifestFormatException("version '$version' has an out-of-range segment")102        }103    }104}105106/**107 * True when a parsed APK archive is the package and version this updater108 * belongs to. Pure: takes the values PackageManager already extracted so the109 * Android side stays at the call site and this stays unit-testable.110 *111 * The package must be an exact match. In particular, a production build must112 * never hand a separately-installable `.debug` package to Android's installer.113 * Debug builds still work in their own process because the caller supplies114 * that process's exact `Context.packageName` as [expectedPackage].115 */116fun isExpectedApk(117    packageName: String?,118    versionName: String?,119    versionCode: Long,120    expectedPackage: String,121    expectedVersionName: String,122    currentVersionCode: Long,123): Boolean {124    if (packageName != expectedPackage) return false125    if (versionName != expectedVersionName) return false126    // Kepr's release manifest carries the semantic version but not the Android127    // versionCode.  The enforceable contract is therefore exact SemVer plus a128    // strictly monotonic code, which blocks equal-version replays and downgrades129    // while still allowing Gradle to choose the next code independently.130    return versionCode > currentVersionCode131}132133/**134 * Select the PackageInfo version-code field without loading the API-28-only135 * property on Oreo.  Keeping the branch as a pure helper lets JVM tests cover136 * both sides even though the unit-test runner is not a real API-26 device.137 */138internal fun packageVersionCodeForSdk(139    sdkInt: Int,140    legacyVersionCode: Int,141    modernVersionCode: Long,142): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()143144/**145 * Select the signing-info flag set for the minimum supported API.  The flag146 * values are supplied by the Android caller so this helper remains a plain147 * integer decision that can be tested without an SDK emulator.148 */149internal fun packageSigningInfoFlagsForSdk(150    sdkInt: Int,151    legacyFlags: Int,152    modernFlags: Int,153): Int = if (sdkInt >= 28) modernFlags else legacyFlags154155/**156 * Check the direction of an APK signing-key rotation against the installed157 * package's current signer set.158 *159 * A normal update has the same current signer set. A forward v3 rotation has160 * the installed current signer in the archive's proof-of-rotation history and161 * a new current signer. The reverse direction (an old key with a higher162 * versionCode) is deliberately rejected: an archive must prove that it is a163 * descendant of the key currently protecting the installed app, not merely164 * share any historical certificate with it. Equal signer-set cardinality also165 * prevents a multi-signer package from being reduced to a subset.166 *167 * API 26/27 callers pass an empty [archiveHistory], making this an exact168 * signature-set comparison on platforms without [android.content.pm.SigningInfo].169 */170internal fun isSigningLineageCompatible(171    archiveCurrent: Set<String>,172    archiveHistory: Set<String>,173    installedCurrent: Set<String>,174): Boolean {175    if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false176    if (archiveCurrent.size != installedCurrent.size) return false177    return installedCurrent.all { it in archiveCurrent || it in archiveHistory }178}179180/**181 * Parse a Kepr release manifest.182 *183 * Two contract wrinkles are handled deliberately:184 * - The notes key is `notes` canonically, but the published Guidelines still show185 *   the retired `release_notes`. Both are accepted, `notes` winning.186 * - `optString` on a JSON null yields the literal string "null" in org.json, which187 *   would render as changelog text. [optTextOrNull] rejects that class of value188 *   outright rather than trusting the caller to notice.189 *190 * Security note (H-08): the canonical platform key is required and the191 * sole-artifact fallback is gone. A Kepr build that only published, say,192 * `android-x86` would have been accepted before and pointed an Android phone at193 * the wrong binary; now it is rejected outright. A signed release-manifest194 * channel is the real fix — that is an architectural change and is out of scope195 * for H-08.196 */197fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {198    val root = runCatching { JSONObject(body) }199        .getOrElse { throw ManifestFormatException("manifest is not JSON") }200201    val version = root.optTextOrNull("version")202        ?: throw ManifestFormatException("manifest has no version")203    validateSemver(version)204205    val platforms = root.optJSONObject("platforms")206        ?: throw ManifestFormatException("manifest has no platforms")207208    val platform = platforms.optJSONObject(platformKey)209        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")210211    val url = platform.optTextOrNull("url")212        ?: throw ManifestFormatException("artifact has no url")213    val checksum = parseArtifactChecksum(platform)214        ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")215216    return ReleaseManifest(217        version = version,218        url = url,219        checksum = checksum,220        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),221    )222}223224/**225 * Extract a `blake3:<hex>` (preferred) or `sha256:<hex>` checksum from a Kepr226 * artifact object. Three shapes are accepted:227 * - Modern: separate `blake3` and/or `sha256` top-level fields on the artifact.228 *   blake3 wins when both are present.229 * - Legacy: a single `checksum` field shaped as `<algorithm>:<64 hex>`. Older230 *   Kepr builds used this and the existing tests still pin it.231 * The string is normalized to `<algorithm>:<64 lowercase hex>` so232 * [parseChecksum] downstream can validate it.233 */234private fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {235    val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)236    if (blake3 != null) return "blake3:$blake3"237    val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)238    if (sha256 != null) return "sha256:$sha256"239    return artifact.optTextOrNull("checksum")240}241242private fun isHex64(value: String): Boolean =243    value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }244245/** Non-blank string value, or null — never JSON null surfaced as the text "null". */246internal fun JSONObject.optTextOrNull(key: String): String? {247    if (!has(key) || isNull(key)) return null248    return optString(key).trim().takeIf { it.isNotEmpty() }249}250251/* ─────────────────────────────  version compare  ───────────────────────────── */252253/**254 * Strip everything that isn't the numeric release, so a debug build255 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was256 * built from rather than as an unrelated string.257 */258fun normalizeVersion(version: String): String =259    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')260261/**262 * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong.263 *264 * Tolerant on purpose, and only safely so: both untrusted entry points265 * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run266 * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the267 * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a268 * malformed local version string into a crash on every update check.269 */270fun compareVersions(a: String, b: String): Int {271    val left = normalizeVersion(a).split('.')272    val right = normalizeVersion(b).split('.')273    for (i in 0 until maxOf(left.size, right.size)) {274        val x = left.getOrNull(i)?.toIntOrNull() ?: 0275        val y = right.getOrNull(i)?.toIntOrNull() ?: 0276        if (x != y) return x.compareTo(y)277    }278    return 0279}280281fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0282283/* ──────────────────────────────────  checksum  ─────────────────────────────── */284285data class ExpectedChecksum(val algorithm: String, val hex: String)286287class ChecksumMismatchException(message: String) : Exception(message)288289private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")290291/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */292fun parseChecksum(raw: String): ExpectedChecksum? {293    val separator = raw.indexOf(':')294    if (separator <= 0) return null295    val algorithm = raw.substring(0, separator).trim().lowercase()296    val hex = raw.substring(separator + 1).trim().lowercase()297    if (algorithm !in SUPPORTED_CHECKSUMS) return null298    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null299    return ExpectedChecksum(algorithm, hex)300}301302fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {303    "blake3" -> {304        val digest = Blake3Digest(32)305        val buffer = ByteArray(64 * 1024)306        while (true) {307            val read = input.read(buffer)308            if (read <= 0) break309            digest.update(buffer, 0, read)310        }311        val out = ByteArray(digest.digestSize)312        digest.doFinal(out, 0)313        out.toHex()314    }315    "sha256" -> {316        val digest = MessageDigest.getInstance("SHA-256")317        val buffer = ByteArray(64 * 1024)318        while (true) {319            val read = input.read(buffer)320            if (read <= 0) break321            digest.update(buffer, 0, read)322        }323        digest.digest().toHex()324    }325    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")326}327328fun hashFile(file: File, algorithm: String): String =329    file.inputStream().use { hashStream(it, algorithm) }330331private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }