UpdateManifest.kt
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 * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its65 * `<os>-<arch>` platform vocabulary. This is the key the release manifest is66 * published under, so it is the key the updater must look up. It has to stay67 * identical to `platforms =` in this repo's `kepr.build` and to Kepr's68 * `release_platforms` allowlist; changing any one alone breaks APK publishing.69 */70const val ANDROID_PLATFORM_KEY = "android-arm64"7172/**73 * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves74 * headroom for growth without letting a hostile manifest exhaust cache or disk.75 */76const val MAX_APK_BYTES = 50L * 1024 * 10247778/**79 * Strict SemVer core: exactly three non-empty, non-leading-zero decimal80 * components, each small enough to fit in an Int. Anything else (NaN segments,81 * extra components, negatives, overflow) is refused — the manifest's version82 * is later used to derive the cache filename, so a permissive parser here would83 * let `..` or `/` reach the filesystem.84 */85private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""")8687internal fun validateSemver(version: String) {88 if (!STRICT_SEMVER.matches(version)) {89 throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch")90 }91 version.split('.').forEach { seg ->92 // STRICT_SEMVER already excludes non-digits and leading zeros, so this93 // overflow check is the only thing left standing between "9999999999"94 // and a silently-wrapped compareVersions result.95 if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) {96 throw ManifestFormatException("version '$version' has an out-of-range segment")97 }98 }99}100101/**102 * True when a parsed APK archive is the package and version this updater103 * belongs to. Pure: takes the values PackageManager already extracted so the104 * Android side stays at the call site and this stays unit-testable.105 *106 * The package must be an exact match. In particular, a production build must107 * never hand a separately-installable `.debug` package to Android's installer.108 * Debug builds still work in their own process because the caller supplies109 * that process's exact `Context.packageName` as [expectedPackage].110 */111fun isExpectedApk(112 packageName: String?,113 versionName: String?,114 versionCode: Long,115 expectedPackage: String,116 expectedVersionName: String,117 currentVersionCode: Long,118): Boolean {119 if (packageName != expectedPackage) return false120 if (versionName != expectedVersionName) return false121 // Kepr's release manifest carries the semantic version but not the Android122 // versionCode. The enforceable contract is therefore exact SemVer plus a123 // strictly monotonic code, which blocks equal-version replays and downgrades124 // while still allowing Gradle to choose the next code independently.125 return versionCode > currentVersionCode126}127128/**129 * Select the PackageInfo version-code field without loading the API-28-only130 * property on Oreo. Keeping the branch as a pure helper lets JVM tests cover131 * both sides even though the unit-test runner is not a real API-26 device.132 */133internal fun packageVersionCodeForSdk(134 sdkInt: Int,135 legacyVersionCode: Int,136 modernVersionCode: Long,137): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()138139/**140 * Select the signing-info flag set for the minimum supported API. The flag141 * values are supplied by the Android caller so this helper remains a plain142 * integer decision that can be tested without an SDK emulator.143 */144internal fun packageSigningInfoFlagsForSdk(145 sdkInt: Int,146 legacyFlags: Int,147 modernFlags: Int,148): Int = if (sdkInt >= 28) modernFlags else legacyFlags149150/**151 * Check the direction of an APK signing-key rotation against the installed152 * package's current signer set.153 *154 * A normal update has the same current signer set. A forward v3 rotation has155 * the installed current signer in the archive's proof-of-rotation history and156 * a new current signer. The reverse direction (an old key with a higher157 * versionCode) is deliberately rejected: an archive must prove that it is a158 * descendant of the key currently protecting the installed app, not merely159 * share any historical certificate with it. Equal signer-set cardinality also160 * prevents a multi-signer package from being reduced to a subset.161 *162 * API 26/27 callers pass an empty [archiveHistory], making this an exact163 * signature-set comparison on platforms without [android.content.pm.SigningInfo].164 */165internal fun isSigningLineageCompatible(166 archiveCurrent: Set<String>,167 archiveHistory: Set<String>,168 installedCurrent: Set<String>,169): Boolean {170 if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false171 if (archiveCurrent.size != installedCurrent.size) return false172 return installedCurrent.all { it in archiveCurrent || it in archiveHistory }173}174175/**176 * Parse a Kepr release manifest.177 *178 * Two contract wrinkles are handled deliberately:179 * - The notes key is `notes` canonically, but the published Guidelines still show180 * the retired `release_notes`. Both are accepted, `notes` winning.181 * - `optString` on a JSON null yields the literal string "null" in org.json, which182 * would render as changelog text. [optTextOrNull] rejects that class of value183 * outright rather than trusting the caller to notice.184 *185 * Security note (H-08): the canonical platform key is required and the186 * sole-artifact fallback is gone. A Kepr build that only published, say,187 * `android-x86` would have been accepted before and pointed an Android phone at188 * the wrong binary; now it is rejected outright. A signed release-manifest189 * channel is the real fix — that is an architectural change and is out of scope190 * for H-08.191 */192fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {193 val root = runCatching { JSONObject(body) }194 .getOrElse { throw ManifestFormatException("manifest is not JSON") }195196 val version = root.optTextOrNull("version")197 ?: throw ManifestFormatException("manifest has no version")198 validateSemver(version)199200 val platforms = root.optJSONObject("platforms")201 ?: throw ManifestFormatException("manifest has no platforms")202203 val platform = platforms.optJSONObject(platformKey)204 ?: throw ManifestFormatException("manifest has no '$platformKey' artifact")205206 val url = platform.optTextOrNull("url")207 ?: throw ManifestFormatException("artifact has no url")208 val checksum = platform.optTextOrNull("checksum")209 ?: throw ManifestFormatException("artifact has no checksum")210211 return ReleaseManifest(212 version = version,213 url = url,214 checksum = checksum,215 notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"),216 )217}218219/** Non-blank string value, or null — never JSON null surfaced as the text "null". */220internal fun JSONObject.optTextOrNull(key: String): String? {221 if (!has(key) || isNull(key)) return null222 return optString(key).trim().takeIf { it.isNotEmpty() }223}224225/* ───────────────────────────── version compare ───────────────────────────── */226227/**228 * Strip everything that isn't the numeric release, so a debug build229 * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was230 * built from rather than as an unrelated string.231 */232fun normalizeVersion(version: String): String =233 version.trim().removePrefix("v").substringBefore('-').substringBefore('+')234235/**236 * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong.237 *238 * Tolerant on purpose, and only safely so: both untrusted entry points239 * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run240 * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the241 * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a242 * malformed local version string into a crash on every update check.243 */244fun compareVersions(a: String, b: String): Int {245 val left = normalizeVersion(a).split('.')246 val right = normalizeVersion(b).split('.')247 for (i in 0 until maxOf(left.size, right.size)) {248 val x = left.getOrNull(i)?.toIntOrNull() ?: 0249 val y = right.getOrNull(i)?.toIntOrNull() ?: 0250 if (x != y) return x.compareTo(y)251 }252 return 0253}254255fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0256257/* ────────────────────────────────── checksum ─────────────────────────────── */258259data class ExpectedChecksum(val algorithm: String, val hex: String)260261class ChecksumMismatchException(message: String) : Exception(message)262263private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256")264265/** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */266fun parseChecksum(raw: String): ExpectedChecksum? {267 val separator = raw.indexOf(':')268 if (separator <= 0) return null269 val algorithm = raw.substring(0, separator).trim().lowercase()270 val hex = raw.substring(separator + 1).trim().lowercase()271 if (algorithm !in SUPPORTED_CHECKSUMS) return null272 if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null273 return ExpectedChecksum(algorithm, hex)274}275276fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) {277 "blake3" -> {278 val digest = Blake3Digest(32)279 val buffer = ByteArray(64 * 1024)280 while (true) {281 val read = input.read(buffer)282 if (read <= 0) break283 digest.update(buffer, 0, read)284 }285 val out = ByteArray(digest.digestSize)286 digest.doFinal(out, 0)287 out.toHex()288 }289 "sha256" -> {290 val digest = MessageDigest.getInstance("SHA-256")291 val buffer = ByteArray(64 * 1024)292 while (true) {293 val read = input.read(buffer)294 if (read <= 0) break295 digest.update(buffer, 0, read)296 }297 digest.digest().toHex()298 }299 else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'")300}301302fun hashFile(file: File, algorithm: String): String =303 file.inputStream().use { hashStream(it, algorithm) }304305private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }