package com.peachpasswords.updates import org.bouncycastle.crypto.digests.Blake3Digest import org.json.JSONArray 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?, /** Kepr platform key represented by this flattened manifest. */ val platformKey: String = ANDROID_PLATFORM_KEY, ) { fun toJson(): String = JSONObject().apply { put("version", version) if (notes != null) put("notes", notes) put("platforms", JSONObject().put(platformKey, JSONObject().apply { put("url", url) put(checksum.substringBefore(':'), checksum.substringAfter(':')) })) }.toString() companion object { /** Cached and network manifests share the same strict parser. */ fun fromCachedJson(json: String): ReleaseManifest? = runCatching { parseReleaseManifest(json) }.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) /** * Kepr platform keys for the two APK splits Peach publishes. `android-x86` * carries the x86_64 APK; the shorter public key matches Kepr's platform * vocabulary while Android's ABI name remains `x86_64` inside the archive. */ const val ANDROID_PLATFORM_KEY = "android-arm64" const val ANDROID_X86_PLATFORM_KEY = "android-x86" fun androidPlatformKey(supportedAbis: Array): String? = when { "arm64-v8a" in supportedAbis -> ANDROID_PLATFORM_KEY "x86_64" in supportedAbis -> ANDROID_X86_PLATFORM_KEY else -> null } sealed interface UpdateCheckResult { data class Accepted(val manifest: ReleaseManifest) : UpdateCheckResult data class NoUpdate(val version: String) : UpdateCheckResult data class RollbackSuppressed(val servedVersion: String, val highWaterVersion: String) : UpdateCheckResult } /** * 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. * * The package must be an exact match. In particular, a production build must * never hand a separately-installable `.debug` package to Android's installer. * Debug builds still work in their own process because the caller supplies * that process's exact `Context.packageName` as [expectedPackage]. */ fun isExpectedApk( packageName: String?, versionName: String?, versionCode: Long, expectedPackage: String, expectedVersionName: String, currentVersionCode: Long, ): Boolean { if (packageName != expectedPackage) return false if (versionName != expectedVersionName) return false // Kepr's release manifest carries the semantic version but not the Android // versionCode. The enforceable contract is therefore exact SemVer plus a // strictly monotonic code, which blocks equal-version replays and downgrades // while still allowing Gradle to choose the next code independently. return versionCode > currentVersionCode } /** * Select the PackageInfo version-code field without loading the API-28-only * property on Oreo. Keeping the branch as a pure helper lets JVM tests cover * both sides even though the unit-test runner is not a real API-26 device. */ internal fun packageVersionCodeForSdk( sdkInt: Int, legacyVersionCode: Int, modernVersionCode: Long, ): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong() /** * Select the signing-info flag set for the minimum supported API. The flag * values are supplied by the Android caller so this helper remains a plain * integer decision that can be tested without an SDK emulator. */ internal fun packageSigningInfoFlagsForSdk( sdkInt: Int, legacyFlags: Int, modernFlags: Int, ): Int = if (sdkInt >= 28) modernFlags else legacyFlags /** * Check the direction of an APK signing-key rotation against the installed * package's current signer set. * * A normal update has the same current signer set. A forward v3 rotation has * the installed current signer in the archive's proof-of-rotation history and * a new current signer. The reverse direction (an old key with a higher * versionCode) is deliberately rejected: an archive must prove that it is a * descendant of the key currently protecting the installed app, not merely * share any historical certificate with it. Equal signer-set cardinality also * prevents a multi-signer package from being reduced to a subset. * * API 26/27 callers pass an empty [archiveHistory], making this an exact * signature-set comparison on platforms without [android.content.pm.SigningInfo]. */ internal fun isSigningLineageCompatible( archiveCurrent: Set, archiveHistory: Set, installedCurrent: Set, ): Boolean { if (archiveCurrent.isEmpty() || installedCurrent.isEmpty()) return false if (archiveCurrent.size != installedCurrent.size) return false return installedCurrent.all { it in archiveCurrent || it in archiveHistory } } /** * 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: the canonical platform key is required and the sole-artifact * fallback is gone. A Kepr build that only published, say, `android-x86` would * otherwise point an Android phone at the wrong binary. */ 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") if (!url.startsWith("https://")) { throw ManifestFormatException("artifact url must use https") } val checksum = parseArtifactChecksum(platform) ?: throw ManifestFormatException("artifact has no usable checksum (blake3, sha256, or legacy 'checksum')") return ReleaseManifest( version = version, url = url, checksum = checksum, notes = root.optTextOrNull("notes") ?: root.optTextOrNull("release_notes"), ) } /** * Extract a `blake3:` (preferred) or `sha256:` checksum from a Kepr * artifact object. Three shapes are accepted: * - Modern: separate `blake3` and/or `sha256` top-level fields on the artifact. * blake3 wins when both are present. * - Legacy: a single `checksum` field shaped as `:<64 hex>`. Older * Kepr builds used this and the existing tests still pin it. * The string is normalized to `:<64 lowercase hex>` so * [parseChecksum] downstream can validate it. */ internal fun parseArtifactChecksum(artifact: org.json.JSONObject): String? { val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64) if (blake3 != null) return "blake3:$blake3" val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64) if (sha256 != null) return "sha256:$sha256" return artifact.optTextOrNull("checksum") } private fun isHex64(value: String): Boolean = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' } /** 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() } } /** One published release from Kepr's `releases/history` endpoint. */ data class ReleaseNote(val version: String, val notes: String?) /** * Parse Kepr's `GET /api//releases/history` response: a JSON array of * `{version, notes}` objects, newest first. Notes stay null when a release * shipped none, so the UI decides what to show for it. */ fun parseReleaseHistory(json: String): List { val arr = JSONArray(json) return (0 until arr.length()).map { i -> val obj = arr.getJSONObject(i) ReleaseNote(obj.getString("version"), obj.optTextOrNull("notes")) } } /* ───────────────────────────── 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) }