package com.peachpasswords.updates import org.bouncycastle.crypto.digests.Blake3Digest import org.json.JSONObject import java.io.File import java.io.InputStream import java.security.MessageDigest /** * Kepr's generic release manifest, as returned by * `GET https://kepr.uk/api//releases/latest`, flattened to the single * platform this app can install. * * ```json * { "version": "2.3.68", * "notes": "...", * "platforms": { "android-arm64": { "url": "...", "checksum": "blake3:<64 hex>" } } } * ``` */ data class ReleaseManifest( val version: String, val url: String, val checksum: String, /** Absent — not empty, not "null" — when the release carries no notes. */ val notes: String?, ) { fun toJson(): String = JSONObject().apply { put("version", version) put("url", url) put("checksum", checksum) if (notes != null) put("notes", notes) }.toString() companion object { /** * The cached manifest is re-read straight into the download path, so it * has to clear the same strict-SemVer bar [parseReleaseManifest] applies * to a fresh fetch — otherwise a rewritten cache entry reaches * `downloadAndVerify`, whose filename safety argument rests on that * validation having happened. A malformed cache entry parses to null, * which callers already treat as "no update known". */ fun fromCachedJson(json: String): ReleaseManifest? = runCatching { val o = JSONObject(json) ReleaseManifest( version = o.getString("version").also(::validateSemver), url = o.getString("url"), checksum = o.getString("checksum"), notes = o.optTextOrNull("notes"), ) }.getOrNull() } } class ManifestFormatException(message: String) : Exception(message) /** Thrown when the APK on disk is not the package/version the updater was told to install. */ class ApkIdentityException(message: String) : Exception(message) /** Thrown when the download would exceed [MAX_APK_BYTES] or advertise a larger Content-Length. */ class DownloadSizeException(message: String) : Exception(message) /** * SETTLED — Kepr accepts `android-arm64` as of kepr v1.56.237, matching its * `-` platform vocabulary. This is the key the release manifest is * published under, so it is the key the updater must look up. It has to stay * identical to `platforms =` in this repo's `kepr.build` and to Kepr's * `release_platforms` allowlist; changing any one alone breaks APK publishing. */ const val ANDROID_PLATFORM_KEY = "android-arm64" /** * Hard ceiling on a downloaded APK. Kepr publishes ~24 MB today; 50 MB leaves * headroom for growth without letting a hostile manifest exhaust cache or disk. */ const val MAX_APK_BYTES = 50L * 1024 * 1024 /** * Strict SemVer core: exactly three non-empty, non-leading-zero decimal * components, each small enough to fit in an Int. Anything else (NaN segments, * extra components, negatives, overflow) is refused — the manifest's version * is later used to derive the cache filename, so a permissive parser here would * let `..` or `/` reach the filesystem. */ private val STRICT_SEMVER = Regex("""^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$""") internal fun validateSemver(version: String) { if (!STRICT_SEMVER.matches(version)) { throw ManifestFormatException("version '$version' is not strict SemVer major.minor.patch") } version.split('.').forEach { seg -> // STRICT_SEMVER already excludes non-digits and leading zeros, so this // overflow check is the only thing left standing between "9999999999" // and a silently-wrapped compareVersions result. if (seg.length > 9 || seg.toLong() > Int.MAX_VALUE) { throw ManifestFormatException("version '$version' has an out-of-range segment") } } } /** * True when a parsed APK archive is the package and version this updater * belongs to. Pure: takes the values PackageManager already extracted so the * Android side stays at the call site and this stays unit-testable. * * Both the production package (`com.peachpasswords`) and the debug suffix * variant (`com.peachpasswords.debug`) are accepted so debug builds can still * install their own updates; anything else is rejected outright. */ fun isExpectedApk( packageName: String?, versionCode: Long, expectedPackage: String, expectedVersionCode: Int, ): Boolean { if (packageName != expectedPackage && packageName != "$expectedPackage.debug") return false return versionCode >= expectedVersionCode } /** * Parse a Kepr release manifest. * * Two contract wrinkles are handled deliberately: * - The notes key is `notes` canonically, but the published Guidelines still show * the retired `release_notes`. Both are accepted, `notes` winning. * - `optString` on a JSON null yields the literal string "null" in org.json, which * would render as changelog text. [optTextOrNull] rejects that class of value * outright rather than trusting the caller to notice. * * Security note (H-08): the canonical platform key is required and the * sole-artifact fallback is gone. A Kepr build that only published, say, * `android-x86` would have been accepted before and pointed an Android phone at * the wrong binary; now it is rejected outright. A signed release-manifest * channel is the real fix — that is an architectural change and is out of scope * for H-08. */ fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest { val root = runCatching { JSONObject(body) } .getOrElse { throw ManifestFormatException("manifest is not JSON") } val version = root.optTextOrNull("version") ?: throw ManifestFormatException("manifest has no version") validateSemver(version) val platforms = root.optJSONObject("platforms") ?: throw ManifestFormatException("manifest has no platforms") val platform = platforms.optJSONObject(platformKey) ?: throw ManifestFormatException("manifest has no '$platformKey' artifact") val url = platform.optTextOrNull("url") ?: throw ManifestFormatException("artifact has no url") val checksum = platform.optTextOrNull("checksum") ?: throw ManifestFormatException("artifact has no checksum") return ReleaseManifest( version = version, url = url, checksum = checksum, notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"), ) } /** Non-blank string value, or null — never JSON null surfaced as the text "null". */ internal fun JSONObject.optTextOrNull(key: String): String? { if (!has(key) || isNull(key)) return null return optString(key).trim().takeIf { it.isNotEmpty() } } /* ───────────────────────────── version compare ───────────────────────────── */ /** * Strip everything that isn't the numeric release, so a debug build * ("2.3.67-debug", from the `versionNameSuffix`) compares as the release it was * built from rather than as an unrelated string. */ fun normalizeVersion(version: String): String = version.trim().removePrefix("v").substringBefore('-').substringBefore('+') /** * Numeric-segment comparison — "2.3.67" < "2.3.100", which a string compare gets wrong. * * Tolerant on purpose, and only safely so: both untrusted entry points * ([parseReleaseManifest] and [ReleaseManifest.fromCachedJson]) run * [validateSemver] first, so the `?: 0` fallback is only ever reachable for the * locally built `BuildConfig.VERSION_NAME`. Throwing here instead would turn a * malformed local version string into a crash on every update check. */ fun compareVersions(a: String, b: String): Int { val left = normalizeVersion(a).split('.') val right = normalizeVersion(b).split('.') for (i in 0 until maxOf(left.size, right.size)) { val x = left.getOrNull(i)?.toIntOrNull() ?: 0 val y = right.getOrNull(i)?.toIntOrNull() ?: 0 if (x != y) return x.compareTo(y) } return 0 } fun isNewerVersion(candidate: String, current: String): Boolean = compareVersions(candidate, current) > 0 /* ────────────────────────────────── checksum ─────────────────────────────── */ data class ExpectedChecksum(val algorithm: String, val hex: String) class ChecksumMismatchException(message: String) : Exception(message) private val SUPPORTED_CHECKSUMS = setOf("blake3", "sha256") /** `blake3:<64 hex>` (Kepr's default) or `sha256:<64 hex>`. Anything else is refused. */ fun parseChecksum(raw: String): ExpectedChecksum? { val separator = raw.indexOf(':') if (separator <= 0) return null val algorithm = raw.substring(0, separator).trim().lowercase() val hex = raw.substring(separator + 1).trim().lowercase() if (algorithm !in SUPPORTED_CHECKSUMS) return null if (hex.length != 64 || hex.any { it !in "0123456789abcdef" }) return null return ExpectedChecksum(algorithm, hex) } fun hashStream(input: InputStream, algorithm: String): String = when (algorithm) { "blake3" -> { val digest = Blake3Digest(32) val buffer = ByteArray(64 * 1024) while (true) { val read = input.read(buffer) if (read <= 0) break digest.update(buffer, 0, read) } val out = ByteArray(digest.digestSize) digest.doFinal(out, 0) out.toHex() } "sha256" -> { val digest = MessageDigest.getInstance("SHA-256") val buffer = ByteArray(64 * 1024) while (true) { val read = input.read(buffer) if (read <= 0) break digest.update(buffer, 0, read) } digest.digest().toHex() } else -> throw ChecksumMismatchException("unsupported checksum algorithm '$algorithm'") } fun hashFile(file: File, algorithm: String): String = file.inputStream().use { hashStream(it, algorithm) } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }