UpdateManifest.kt

14.7 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    /** Base64 Ed25519 signature over the canonical release metadata payload. */28    val signature: String = "",29    /** Provenance source bound into [signature]. */30    val provenanceSource: String = "",31    /** Kepr platform key represented by this flattened manifest. */32    val platformKey: String = ANDROID_PLATFORM_KEY,33) {34    fun toJson(): String = JSONObject().apply {35        put("version", version)36        if (notes != null) put("notes", notes)37        put("platforms", JSONObject().put(platformKey, JSONObject().apply {38            put("url", url)39            put(checksum.substringBefore(':'), checksum.substringAfter(':'))40            put("provenance", JSONObject().apply {41                put("version", version)42                put("platform", platformKey)43                put("checksum", checksum)44                put("source", provenanceSource)45            })46            put("signature", signature)47        }))48    }.toString()4950    companion object {51        /**52         * The cached manifest is re-read straight into the download path, so it53         * has to clear the same strict-SemVer bar [parseReleaseManifest] applies54         * to a fresh fetch — otherwise a rewritten cache entry reaches55         * `downloadAndVerify`, whose filename safety argument rests on that56         * validation having happened. A malformed cache entry parses to null,57         * which callers already treat as "no update known".58         */59        fun fromCachedJson(json: String): ReleaseManifest? = runCatching {60            parseSignedReleaseManifest(json)61        }.getOrNull()62    }63}6465class ManifestFormatException(message: String) : Exception(message)6667/** Thrown when the APK on disk is not the package/version the updater was told to install. */68class ApkIdentityException(message: String) : Exception(message)6970/** Thrown when the download would exceed [MAX_APK_BYTES] or advertise a larger Content-Length. */71class DownloadSizeException(message: String) : Exception(message)7273/**74 * Kepr platform keys for the two APK splits Peach publishes. `android-x86`75 * carries the x86_64 APK; the shorter public key matches Kepr's platform76 * vocabulary while Android's ABI name remains `x86_64` inside the archive.77 */78const val ANDROID_PLATFORM_KEY = "android-arm64"79const val ANDROID_X86_PLATFORM_KEY = "android-x86"8081fun androidPlatformKey(supportedAbis: Array<String>): String? = when {82    "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY83    "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY84    else -> null85}8687/**88 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves89 * headroom for growth without letting a hostile manifest exhaust cache or disk.90 */91const val MAX_APK_BYTES = 50L * 1024 * 10249293/**94 * Strict SemVer core: exactly three non-empty, non-leading-zero decimal95 * components, each small enough to fit in an Int. Anything else (NaN segments,96 * extra components, negatives, overflow) is refused — the manifest's version97 * is later used to derive the cache filename, so a permissive parser here would98 * let `..` or `/` reach the filesystem.99 */100private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")101102internal fun validateSemver(version: String) {103    if (!STRICT_SEMVER.matches(version)) {104        throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")105    }106    version.split('.').forEach { seg ->107        // STRICT_SEMVER already excludes non-digits and leading zeros, so this108        // overflow check is the only thing left standing between "9999999999"109        // and a silently-wrapped compareVersions result.110        if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {111            throw ManifestFormatException("version '$version' has an out-of-range segment")112        }113    }114}115116/**117 * True when a parsed APK archive is the package and version this updater118 * belongs to. Pure: takes the values PackageManager already extracted so the119 * Android side stays at the call site and this stays unit-testable.120 *121 * The package must be an exact match. In particular, a production build must122 * never hand a separately-installable `.debug` package to Android's installer.123 * Debug builds still work in their own process because the caller supplies124 * that process's exact `Context.packageName` as [expectedPackage].125 */126fun isExpectedApk(127    packageName: String?,128    versionName: String?,129    versionCode: Long,130    expectedPackage: String,131    expectedVersionName: String,132    currentVersionCode: Long,133): Boolean {134    if (packageName != expectedPackage) return false135    if (versionName != expectedVersionName) return false136    // Kepr's release manifest carries the semantic version but not the Android137    // versionCode.  The enforceable contract is therefore exact SemVer plus a138    // strictly monotonic code, which blocks equal-version replays and downgrades139    // while still allowing Gradle to choose the next code independently.140    return versionCode > currentVersionCode141}142143/**144 * Select the PackageInfo version-code field without loading the API-28-only145 * property on Oreo.  Keeping the branch as a pure helper lets JVM tests cover146 * both sides even though the unit-test runner is not a real API-26 device.147 */148internal fun packageVersionCodeForSdk(149    sdkInt: Int,150    legacyVersionCode: Int,151    modernVersionCode: Long,152): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()153154/**155 * Select the signing-info flag set for the minimum supported API.  The flag156 * values are supplied by the Android caller so this helper remains a plain157 * integer decision that can be tested without an SDK emulator.158 */159internal fun packageSigningInfoFlagsForSdk(160    sdkInt: Int,161    legacyFlags: Int,162    modernFlags: Int,163): Int = if (sdkInt >= 28) modernFlags else legacyFlags164165/**166 * Check the direction of an APK signing-key rotation against the installed167 * package's current signer set.168 *169 * A normal update has the same current signer set. A forward v3 rotation has170 * the installed current signer in the archive's proof-of-rotation history and171 * a new current signer. The reverse direction (an old key with a higher172 * versionCode) is deliberately rejected: an archive must prove that it is a173 * descendant of the key currently protecting the installed app, not merely174 * share any historical certificate with it. Equal signer-set cardinality also175 * prevents a multi-signer package from being reduced to a subset.176 *177 * API 26/27 callers pass an empty [archiveHistory], making this an exact178 * signature-set comparison on platforms without [android.content.pm.SigningInfo].179 */180internal fun isSigningLineageCompatible(181    archiveCurrent: Set<String>,182    archiveHistory: Set<String>,183    installedCurrent: Set<String>,184): Boolean {185    if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false186    if (archiveCurrent.size != installedCurrent.size) return false187    return installedCurrent.all { it in archiveCurrent || it in archiveHistory }188}189190/**191 * Parse a Kepr release manifest.192 *193 * Two contract wrinkles are handled deliberately:194 * - The notes key is `notes` canonically, but the published Guidelines still show195 *   the retired `release_notes`. Both are accepted, `notes` winning.196 * - `optString` on a JSON null yields the literal string "null" in org.json, which197 *   would render as changelog text. [optTextOrNull] rejects that class of value198 *   outright rather than trusting the caller to notice.199 *200 * Security note: the canonical platform key is required and the sole-artifact201 * fallback is gone. A Kepr build that only published, say, `android-x86` would202 * otherwise point an Android phone at the wrong binary; signed metadata is now203 * verified by [parseSignedReleaseManifest] before this parser is used by the204 * update network path.205 */206fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {207    val root = runCatching { JSONObject(body) }208        .getOrElse { throw ManifestFormatException("manifest is not JSON") }209210    val version = root.optTextOrNull("version")211        ?: throw ManifestFormatException("manifest has no version")212    validateSemver(version)213214    val platforms = root.optJSONObject("platforms")215        ?: throw ManifestFormatException("manifest has no platforms")216217    val platform = platforms.optJSONObject(platformKey)218        ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")219220    val url = platform.optTextOrNull("url")221        ?: throw ManifestFormatException("artifact has no url")222    val checksum = parseArtifactChecksum(platform)223        ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')")224225    return ReleaseManifest(226        version = version,227        url = url,228        checksum = checksum,229        notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),230    )231}232233/**234 * Extract a `blake3:<hex>` (preferred) or `sha256:<hex>` checksum from a Kepr235 * artifact object. Three shapes are accepted:236 * - Modern: separate `blake3` and/or `sha256` top-level fields on the artifact.237 *   blake3 wins when both are present.238 * - Legacy: a single `checksum` field shaped as `<algorithm>:<64 hex>`. Older239 *   Kepr builds used this and the existing tests still pin it.240 * The string is normalized to `<algorithm>:<64 lowercase hex>` so241 * [parseChecksum] downstream can validate it.242 */243internal fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {244    val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)245    if (blake3 != null) return "blake3:$blake3"246    val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)247    if (sha256 != null) return "sha256:$sha256"248    return artifact.optTextOrNull("checksum")249}250251private fun isHex64(value: String): Boolean =252    value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }253254/** Non-blank string value, or null — never JSON null surfaced as the text "null". */255internal fun JSONObject.optTextOrNull(key: String): String? {256    if (!has(key) || isNull(key)) return null257    return optString(key).trim().takeIf { it.isNotEmpty() }258}259260/** One published release from Kepr's `releases/history` endpoint. */261data class ReleaseNote(val version: String, val notes: String?)262263/**264 * Parse Kepr's `GET /api/<repo>/releases/history` response: a JSON array of265 * `{version, notes}` objects, newest first. Notes stay null when a release266 * shipped none, so the UI decides what to show for it.267 */268fun parseReleaseHistory(json: String): List<ReleaseNote> {269    val arr = JSONArray(json)270    return (0 until arr.length()).map { i ->271        val obj = arr.getJSONObject(i)272        ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes"))273    }274}275276/* ─────────────────────────────  version compare  ───────────────────────────── */277278/**279 * Strip everything that isn't the numeric release, so a debug build280 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was281 * built from rather than as an unrelated string.282 */283fun normalizeVersion(version: String): String =284    version.trim().removePrefix("v").substringBefore('-').substringBefore('+')285286/**287 * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong.288 *289 * Tolerant on purpose, and only safely so: both untrusted entry points290 * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run291 * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the292 * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a293 * malformed local version string into a crash on every update check.294 */295fun compareVersions(a: String, b: String): Int {296    val left = normalizeVersion(a).split('.')297    val right = normalizeVersion(b).split('.')298    for (i in 0 until maxOf(left.size, right.size)) {299        val x = left.getOrNull(i)?.toIntOrNull() ?: 0300        val y = right.getOrNull(i)?.toIntOrNull() ?: 0301        if (x != y) return x.compareTo(y)302    }303    return 0304}305306fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0307308/* ──────────────────────────────────  checksum  ─────────────────────────────── */309310data class ExpectedChecksum(val algorithm: String, val hex: String)311312class ChecksumMismatchException(message: String) : Exception(message)313314private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")315316/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */317fun parseChecksum(raw: String): ExpectedChecksum? {318    val separator = raw.indexOf(':')319    if (separator <= 0) return null320    val algorithm = raw.substring(0, separator).trim().lowercase()321    val hex = raw.substring(separator + 1).trim().lowercase()322    if (algorithm !in SUPPORTED_CHECKSUMS) return null323    if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null324    return ExpectedChecksum(algorithm, hex)325}326327fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {328    "blake3" -> {329        val digest = Blake3Digest(32)330        val buffer = ByteArray(64 * 1024)331        while (true) {332            val read = input.read(buffer)333            if (read <= 0) break334            digest.update(buffer, 0, read)335        }336        val out = ByteArray(digest.digestSize)337        digest.doFinal(out, 0)338        out.toHex()339    }340    "sha256" -> {341        val digest = MessageDigest.getInstance("SHA-256")342        val buffer = ByteArray(64 * 1024)343        while (true) {344            val read = input.read(buffer)345            if (read <= 0) break346            digest.update(buffer, 0, read)347        }348        digest.digest().toHex()349    }350    else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")351}352353fun hashFile(file: File, algorithm: String): String =354    file.inputStream().use { hashStream(it, algorithm) }355356private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }