519a145e56fe
fix: Harden updater and release publication gates (v2.8.4)
- time
- 2026-07-29 17:31 UTC · 2 months ago
- machine
- koh2
- parent
- 0a6a3463e47b
↓ peach-android-519a145e.tar.gzatlas at this save
files changed8 files
VERSION+1 −1app/build.gradle+18 −6app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+61 −6app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+108 −17app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+124 −12scripts/kepr-build-apk.sh+106 −43scripts/kepr-build-helpers.sh+168 −0scripts/test-kepr-build-helpers.sh+75 −0app/build.gradle+18 −6
@@ -12,16 +12,28 @@1212 namespace "com.peachpasswords"1313 compileSdk 351414 15+ // VERSION is the single semantic-version authority. versionCode remains16+ // Android/Gradle metadata because Kepr's manifest does not carry it; the17+ // updater enforces exact VERSION plus a strictly newer code.18+ def versionAuthorityFile = rootProject.file("VERSION")19+ if (!versionAuthorityFile.isFile()) {20+ throw new GradleException("VERSION is missing; it is the semantic-version authority")21+ }22+ def semanticVersion = versionAuthorityFile.getText("UTF-8").trim()23+ if (!(semanticVersion ==~ /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/)) {24+ throw new GradleException("VERSION must contain exactly one strict major.minor.patch value")25+ }26+ if (semanticVersion.split("\\.").any { it.toBigInteger() > Integer.MAX_VALUE }) {27+ throw new GradleException("VERSION contains a semantic-version component outside Gradle's supported range")28+ }29+1530 defaultConfig {1631 applicationId "com.peachpasswords"1732 minSdk 261833 targetSdk 3519- // Canonical version source (audit M-13). Bump versionName and versionCode20- // here for a release. The root VERSION file and docs/NOISE-CORE-PROVENANCE.md21- // should be derived from these values rather than maintained independently;22- // the three sources drift otherwise.23- versionCode 24014524- versionName "2.8.3"34+ // Keep the Android code in Gradle; the semantic name comes from root VERSION.35+ versionCode 24014636+ versionName semanticVersion2537 2638 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2739 vectorDrawables {app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+61 −6
@@ -103,18 +103,73 @@103103 * belongs to. Pure: takes the values PackageManager already extracted so the104104 * Android side stays at the call site and this stays unit-testable.105105 *106- * Both the production package (`com.peachpasswords`) and the debug suffix107- * variant (`com.peachpasswords.debug`) are accepted so debug builds can still108- * install their own updates; anything else is rejected outright.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].109110 */110111 fun isExpectedApk(111112 packageName: String?,113+ versionName: String?,112114 versionCode: Long,113115 expectedPackage: String,114- expectedVersionCode: Int,116+ expectedVersionName: String,117+ currentVersionCode: Long,115118 ): Boolean {116- if (packageName != expectedPackage && packageName != "$expectedPackage.debug") return false117- return versionCode >= expectedVersionCode119+ 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+}127+128+/**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+ */133+internal fun packageVersionCodeForSdk(134+ sdkInt: Int,135+ legacyVersionCode: Int,136+ modernVersionCode: Long,137+): Long = if (sdkInt >= 28) modernVersionCode else legacyVersionCode.toLong()138+139+/**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+ */144+internal fun packageSigningInfoFlagsForSdk(145+ sdkInt: Int,146+ legacyFlags: Int,147+ modernFlags: Int,148+): Int = if (sdkInt >= 28) modernFlags else legacyFlags149+150+/**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+ */165+internal 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 }118173 }119174 120175 /**app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+108 −17
@@ -2,10 +2,14 @@22 33 import android.content.Context44 import android.content.Intent5+import android.content.pm.PackageInfo56 import android.content.pm.PackageManager7+import android.content.pm.Signature68 import android.net.Uri9+import android.os.Build710 import android.provider.Settings811 import androidx.core.content.FileProvider12+import androidx.annotation.RequiresApi913 import com.peachpasswords.BuildConfig1014 import dagger.hilt.android.qualifiers.ApplicationContext1115 import kotlinx.coroutines.Dispatchers@@ -17,6 +21,7 @@1721 import okhttp3.Request1822 import java.io.File1923 import java.io.IOException24+import java.security.MessageDigest2025 import java.util.concurrent.TimeUnit2126 import javax.inject.Inject2227 import javax.inject.Singleton@@ -142,7 +147,7 @@142147 // Even on a cache hit we re-prove identity: a tampered file in the143148 // cache slot must not reach the installer any more than a tampered144149 // download must.145- verifyApkIdentity(target)150+ verifyApkIdentity(target, manifest.version)146151 onProgress(1f)147152 return@withContext target148153 }@@ -202,37 +207,123 @@202207 "${expected.algorithm} mismatch — expected ${expected.hex}, got $actual",203208 )204209 }205- verifyApkIdentity(target)210+ verifyApkIdentity(target, manifest.version)206211 onProgress(1f)207212 target208213 }209214 210215 /**211- * Confirm the cached APK is the package and version this updater belongs212- * to. PackageManager is happy to parse any well-formed APK the cache may213- * contain (or, in the future, a hostile Kepr build with a substituted214- * manifest); the package-name and version-code checks are what stop an215- * arbitrary binary from being handed to the installer.216+ * Confirm the cached APK is the exact package/version this updater belongs217+ * to and that its signer is the currently installed Peach signer (or a218+ * forward v3 descendant with a proof-of-rotation back to it). PackageManager219+ * is happy to parse any well-formed APK the cache may contain, so both220+ * identity and signing lineage are checked before the installer sees it.216221 */217- private fun verifyApkIdentity(apk: File) {218- val info = context.packageManager.getPackageArchiveInfo(222+ private fun verifyApkIdentity(apk: File, expectedVersionName: String) {223+ val packageManager = context.packageManager224+ val info = packageManager.getPackageArchiveInfo(219225 apk.absolutePath,220- 0,221- ) ?: throw ApkIdentityException("APK could not be parsed as an Android package")226+ signingInfoFlags(),227+ ) ?: rejectIdentity(apk, "APK could not be parsed as an Android package")228+ val archiveVersionCode = archiveVersionCode(info)222229 if (!isExpectedApk(223230 packageName = info.packageName,224- versionCode = info.longVersionCode,231+ versionName = info.versionName,232+ versionCode = archiveVersionCode,225233 expectedPackage = context.packageName,226- expectedVersionCode = BuildConfig.VERSION_CODE,234+ expectedVersionName = expectedVersionName,235+ currentVersionCode = BuildConfig.VERSION_CODE.toLong(),227236 )228237 ) {229- apk.delete()230- throw ApkIdentityException(238+ rejectIdentity(239+ apk,231240 "APK identity mismatch: package=${info.packageName} " +232- "versionCode=${info.longVersionCode}, expected ${context.packageName} " +233- ">=${BuildConfig.VERSION_CODE}",241+ "versionName=${info.versionName}, versionCode=$archiveVersionCode, " +242+ "expected ${context.packageName} versionName=$expectedVersionName " +243+ "and versionCode>${BuildConfig.VERSION_CODE}",234244 )235245 }246+247+ val installed = runCatching {248+ packageManager.getPackageInfo(context.packageName, signingInfoFlags())249+ }.getOrElse {250+ rejectIdentity(apk, "installed Peach package could not be inspected")251+ }252+ val archiveSigning = signingCertificates(info)253+ val installedSigning = signingCertificates(installed)254+ if (!isSigningLineageCompatible(255+ archiveCurrent = archiveSigning.current,256+ archiveHistory = archiveSigning.history,257+ installedCurrent = installedSigning.current,258+ )259+ ) {260+ rejectIdentity(apk, "APK signing certificate is not in the installed Peach lineage")261+ }262+ }263+264+ private data class SigningCertificates(265+ val current: Set<String>,266+ val history: Set<String>,267+ )268+269+ /**270+ * `PackageInfo.longVersionCode` and signing history were added in API 28;271+ * keep the archive gate runnable on the app's API-26 minimum instead of272+ * letting lint-approved source crash on Oreo devices.273+ */274+ @Suppress("DEPRECATION", "InlinedApi")275+ private fun signingInfoFlags(): Int =276+ packageSigningInfoFlagsForSdk(277+ sdkInt = Build.VERSION.SDK_INT,278+ legacyFlags = PackageManager.GET_SIGNATURES,279+ modernFlags = PackageManager.GET_SIGNING_CERTIFICATES,280+ )281+282+ @Suppress("DEPRECATION", "InlinedApi")283+ private fun archiveVersionCode(info: PackageInfo): Long =284+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {285+ packageVersionCodeForSdk(286+ sdkInt = Build.VERSION.SDK_INT,287+ legacyVersionCode = info.versionCode,288+ modernVersionCode = modernVersionCode(info),289+ )290+ } else {291+ packageVersionCodeForSdk(292+ sdkInt = Build.VERSION.SDK_INT,293+ legacyVersionCode = info.versionCode,294+ modernVersionCode = info.versionCode.toLong(),295+ )296+ }297+298+ @RequiresApi(Build.VERSION_CODES.P)299+ private fun modernVersionCode(info: PackageInfo): Long = info.longVersionCode300+301+ @Suppress("DEPRECATION", "InlinedApi")302+ private fun signingCertificates(info: PackageInfo): SigningCertificates {303+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {304+ val signingInfo = info.signingInfo ?: return SigningCertificates(emptySet(), emptySet())305+ val current = signingInfo.apkContentsSigners.orEmpty()306+ .map(::certificateSha256)307+ .toSet()308+ val history = signingInfo.signingCertificateHistory.orEmpty()309+ .map(::certificateSha256)310+ .toSet()311+ return SigningCertificates(current = current, history = history)312+ }313+ val current = info.signatures.orEmpty()314+ .map(::certificateSha256)315+ .toSet()316+ return SigningCertificates(current = current, history = emptySet())317+ }318+319+ private fun certificateSha256(signature: Signature): String =320+ MessageDigest.getInstance("SHA-256")321+ .digest(signature.toByteArray())322+ .joinToString("") { byte -> "%02x".format(byte) }323+324+ private fun rejectIdentity(apk: File, message: String): Nothing {325+ apk.delete()326+ throw ApkIdentityException(message)236327 }237328 238329 fun discardDownloads() {app/src/test/java/com/peachpasswords/updates/UpdateManifestTest.kt+124 −12
@@ -45,10 +45,9 @@4545 }4646 4747 @Test48- fun `missing platform falls back to the sole artifact`() {48+ fun `canonical platform artifact is accepted`() {4949 // H-08: the sole-artifact fallback is gone. The canonical50- // android-arm64 key is the only one the updater will accept — but the51- // test fixture is that key, so the canonical path still passes.50+ // android-arm64 key is the only one the updater will accept.5251 val body = """5352 {"version":"2.3.68","platforms":{"android-arm64":5453 {"url":"https://x/y","checksum":"sha256:${"b".repeat(64)}"}}}@@ -195,30 +194,143 @@195194 )196195 }197196 198- /** APK identity gate — must accept only this app's package at this app's version-or-newer. */197+ /** APK identity gate — exact manifest SemVer plus a strictly newer Android versionCode. */199198 @Test200- fun `isExpectedApk accepts production and debug packages at or above the running version`() {201- assertTrue(isExpectedApk("com.peachpasswords", 240146L, "com.peachpasswords", 240145))202- assertTrue(isExpectedApk("com.peachpasswords", 240145L, "com.peachpasswords", 240145))203- assertTrue(isExpectedApk("com.peachpasswords.debug", 240200L, "com.peachpasswords", 240145))199+ fun `isExpectedApk accepts exact manifest version with a strictly newer code`() {200+ assertTrue(201+ isExpectedApk(202+ packageName = "com.peachpasswords",203+ versionName = "2.8.5",204+ versionCode = 240146L,205+ expectedPackage = "com.peachpasswords",206+ expectedVersionName = "2.8.5",207+ currentVersionCode = 240145L,208+ ),209+ )204210 }205211 206212 @Test207213 fun `isExpectedApk rejects a foreign package`() {208- assertFalse(isExpectedApk("com.evil.twin", 999_999L, "com.peachpasswords", 240145))214+ assertFalse(215+ isExpectedApk(216+ "com.evil.twin", "2.8.5", 999_999L,217+ "com.peachpasswords", "2.8.5", 240145L,218+ ),219+ )209220 }210221 211222 @Test223+ fun `isExpectedApk rejects separately installable debug package`() {224+ assertFalse(225+ isExpectedApk(226+ "com.peachpasswords.debug", "2.8.5", 240200L,227+ "com.peachpasswords", "2.8.5", 240145L,228+ ),229+ )230+ }231+232+ @Test212233 fun `isExpectedApk rejects a downgrade`() {213234 // A signed APK with the right package name but a lower versionCode is214235 // still the wrong APK — refuse so a stale or partial cache can't slip215236 // through.216- assertFalse(isExpectedApk("com.peachpasswords", 1L, "com.peachpasswords", 240145))237+ assertFalse(238+ isExpectedApk(239+ "com.peachpasswords", "2.8.5", 240145L,240+ "com.peachpasswords", "2.8.5", 240145L,241+ ),242+ )217243 }218244 219245 @Test220246 fun `isExpectedApk rejects null and blank package names`() {221- assertFalse(isExpectedApk(null, 999L, "com.peachpasswords", 240145))222- assertFalse(isExpectedApk("", 999L, "com.peachpasswords", 240145))247+ assertFalse(248+ isExpectedApk(null, "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),249+ )250+ assertFalse(251+ isExpectedApk("", "2.8.5", 999L, "com.peachpasswords", "2.8.5", 240145L),252+ )253+ }254+255+ @Test256+ fun `isExpectedApk rejects a semantic version mismatch even with a newer code`() {257+ assertFalse(258+ isExpectedApk(259+ "com.peachpasswords", "2.8.6", 240200L,260+ "com.peachpasswords", "2.8.5", 240145L,261+ ),262+ )263+ }264+265+ @Test266+ fun `isExpectedApk rejects an equal versionCode replay`() {267+ assertFalse(268+ isExpectedApk(269+ "com.peachpasswords", "2.8.5", 240145L,270+ "com.peachpasswords", "2.8.5", 240145L,271+ ),272+ )273+ }274+275+ /* ───────────────────── API-26/27 vs API-28+ branches ───────────────────── */276+277+ @Test278+ fun `package version code helper preserves legacy and modern fields`() {279+ assertEquals(240145L, packageVersionCodeForSdk(26, 240145, 9_999_999_999L))280+ assertEquals(240145L, packageVersionCodeForSdk(27, 240145, 9_999_999_999L))281+ assertEquals(9_999_999_999L, packageVersionCodeForSdk(28, 240145, 9_999_999_999L))282+ }283+284+ @Test285+ fun `package signing flags helper selects API boundary`() {286+ assertEquals(1, packageSigningInfoFlagsForSdk(26, 1, 2))287+ assertEquals(1, packageSigningInfoFlagsForSdk(27, 1, 2))288+ assertEquals(2, packageSigningInfoFlagsForSdk(28, 1, 2))289+ }290+291+ /* ─────────────────────── signing-lineage direction ─────────────────────── */292+293+ @Test294+ fun `signing lineage accepts unchanged signer`() {295+ assertTrue(296+ isSigningLineageCompatible(297+ archiveCurrent = setOf("current"),298+ archiveHistory = setOf("current"),299+ installedCurrent = setOf("current"),300+ ),301+ )302+ }303+304+ @Test305+ fun `signing lineage accepts a forward rotation proving the installed signer`() {306+ assertTrue(307+ isSigningLineageCompatible(308+ archiveCurrent = setOf("rotated"),309+ archiveHistory = setOf("installed", "rotated"),310+ installedCurrent = setOf("installed"),311+ ),312+ )313+ }314+315+ @Test316+ fun `signing lineage rejects reverse rotation even when old signer is historical`() {317+ assertFalse(318+ isSigningLineageCompatible(319+ archiveCurrent = setOf("old"),320+ archiveHistory = setOf("old"),321+ installedCurrent = setOf("rotated"),322+ ),323+ )324+ }325+326+ @Test327+ fun `signing lineage rejects a signer-set expansion`() {328+ assertFalse(329+ isSigningLineageCompatible(330+ archiveCurrent = setOf("installed", "attacker"),331+ archiveHistory = setOf("installed", "attacker"),332+ installedCurrent = setOf("installed"),333+ ),334+ )223335 }224336 }scripts/kepr-build-apk.sh+106 −43
@@ -1,72 +1,135 @@11 #!/bin/sh2-# Build the signed release APK for Kepr Builds.2+# Build and publish the signed release APK for Kepr Builds.33 #4-# Signing comes from local.properties (koh-ignored); without it the release APK5-# is unsigned and Android refuses to install it, so fail loudly here rather than6-# publish something nobody can install. After the build we additionally verify7-# the produced artifact (audit M-10): a configured keystore does not by itself8-# prove the APK is signed, that its applicationId and versionCode match the9-# release policy, or that the v2/v3 signing chain is intact.4+# The release keystore is read from local.properties (koh-ignored). The script5+# checks that configuration before starting Gradle, then validates the produced6+# APK's package, version, APK Signature Scheme, and pinned release certificate7+# before copying anything to the Kepr staging directory.108 set -eu119 1210 JAVA_HOME="${JAVA_HOME:-/Applications/Android Studio.app/Contents/jbr/Contents/Home}"1311 export JAVA_HOME1412 15-# Canonical values live in app/build.gradle (defaultConfig.applicationId,16-# defaultConfig.versionCode). Update these constants only when that file17-# changes — they are the post-build cross-check the audit calls for.18-EXPECTED_APPLICATION_ID="com.peachpasswords"19-EXPECTED_VERSION_CODE="240145"13+ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)14+cd "$ROOT_DIR"2015 21-./gradlew --no-configuration-cache :app:assembleRelease16+# Keep the tool-selection and public metadata gates shared with the hermetic17+# negative-path test; this script owns signing/build orchestration only.18+# shellcheck source=scripts/kepr-build-helpers.sh19+. "$ROOT_DIR/scripts/kepr-build-helpers.sh"2220 23-APK=app/build/outputs/apk/release/app-release.apk24-if [ ! -f "$APK" ]; then25- echo "refusing to publish: $APK not produced" >&221+PROPERTIES_FILE="$ROOT_DIR/local.properties"22+if [ ! -r "$PROPERTIES_FILE" ]; then23+ echo "refusing to publish: local.properties is missing or unreadable" >&22624 exit 12725 fi2826 29-APKSIGNER=""30-if [ -n "${ANDROID_HOME:-}" ] && [ -x "${ANDROID_HOME}/build-tools/latest/apksigner" ]; then31- APKSIGNER="${ANDROID_HOME}/build-tools/latest/apksigner"32-elif command -v apksigner >/dev/null 2>&1; then33- APKSIGNER=$(command -v apksigner)34-else35- echo "refusing to publish: apksigner not found (set ANDROID_HOME or add to PATH)" >&227+property() {28+ # Values are inspected only for presence/path checks; never print this file29+ # because it contains the release passwords.30+ sed -n "s/^$1=//p" "$PROPERTIES_FILE" | sed -n '1p'31+}32+33+STORE_FILE_PROPERTY=$(property peachStoreFile)34+STORE_PASSWORD=$(property peachStorePassword)35+KEY_ALIAS=$(property peachKeyAlias)36+KEY_PASSWORD=$(property peachKeyPassword)37+38+if [ -z "$STORE_FILE_PROPERTY" ] || [ -z "$STORE_PASSWORD" ] ||39+ [ -z "$KEY_ALIAS" ] || [ -z "$KEY_PASSWORD" ]; then40+ echo "refusing to publish: incomplete release signing configuration" >&23641 exit 13742 fi3843 39-if ! "$APKSIGNER" verify --print-certs "$APK"; then40- echo "refusing to publish: $APK failed apksigner verify (unsigned or v1-only)" >&244+case "$STORE_FILE_PROPERTY" in45+ /*) STORE_FILE="$STORE_FILE_PROPERTY" ;;46+ *) STORE_FILE="$ROOT_DIR/$STORE_FILE_PROPERTY" ;;47+esac48+if [ ! -f "$STORE_FILE" ] || [ ! -r "$STORE_FILE" ]; then49+ echo "refusing to publish: configured release keystore is missing or unreadable" >&24150 exit 14251 fi4352 44-AAPT2=""45-if [ -n "${ANDROID_HOME:-}" ] && [ -x "${ANDROID_HOME}/build-tools/latest/aapt2" ]; then46- AAPT2="${ANDROID_HOME}/build-tools/latest/aapt2"47-elif command -v aapt2 >/dev/null 2>&1; then48- AAPT2=$(command -v aapt2)49-else50- echo "refusing to publish: aapt2 not found (set ANDROID_HOME or add to PATH)" >&253+# Resolve the SDK from the environment first, then the same local.properties54+# Gradle uses. Pick the newest *versioned* build-tools directory containing both55+# tools; build-tools/latest is intentionally never trusted or consulted.56+SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"57+if [ -z "$SDK_ROOT" ]; then58+ SDK_ROOT=$(property sdk.dir)59+fi60+if [ -z "$SDK_ROOT" ] || [ ! -d "$SDK_ROOT/build-tools" ]; then61+ echo "refusing to publish: Android SDK build-tools directory not found" >&25162 exit 15263 fi5364 54-BADGING=$("$AAPT2" dump badging "$APK")55-ACTUAL_APPID=$(echo "$BADGING" | sed -n "s/^package: name='\([^']*\)'.*/\1/p")56-ACTUAL_VCODE=$(echo "$BADGING" | sed -n "s/^package:.*versionCode='\([^']*\)'.*/\1/p")57-if [ "$ACTUAL_APPID" != "$EXPECTED_APPLICATION_ID" ]; then58- echo "refusing to publish: applicationId '$ACTUAL_APPID' != expected '$EXPECTED_APPLICATION_ID'" >&265+BUILD_TOOLS_DIR=$(find_stable_build_tools_dir "$SDK_ROOT" || true)66+if [ -z "$BUILD_TOOLS_DIR" ]; then67+ echo "refusing to publish: no versioned build-tools directory has apksigner and aapt2" >&25968 exit 16069 fi61-if [ "$ACTUAL_VCODE" != "$EXPECTED_VERSION_CODE" ]; then62- echo "refusing to publish: versionCode '$ACTUAL_VCODE' != expected '$EXPECTED_VERSION_CODE'" >&270+APKSIGNER="$BUILD_TOOLS_DIR/apksigner"71+AAPT2="$BUILD_TOOLS_DIR/aapt2"72+73+ROOT_VERSION=$(read_root_semver "$ROOT_DIR/VERSION" || true)74+if [ -z "$ROOT_VERSION" ]; then75+ echo "refusing to publish: VERSION is missing or not strict semantic version" >&26376 exit 16477 fi6578 66-mkdir -p build/kepr67-cp "$APK" build/kepr/peach-android.apk79+"$ROOT_DIR/gradlew" --no-configuration-cache :app:assembleRelease6880 69-if ! grep -q '^peachStoreFile=' local.properties 2>/dev/null; then70- echo "refusing to publish: no signing key configured in local.properties" >&281+APK="$ROOT_DIR/app/build/outputs/apk/release/app-release.apk"82+METADATA="$ROOT_DIR/app/build/outputs/apk/release/output-metadata.json"83+if [ ! -f "$APK" ] || [ ! -r "$APK" ]; then84+ echo "refusing to publish: release APK was not produced" >&27185 exit 17286 fi87+if [ ! -f "$METADATA" ] || [ ! -r "$METADATA" ]; then88+ echo "refusing to publish: Gradle output metadata was not produced" >&289+ exit 190+fi91+92+BADGING_FILE=$(mktemp "${TMPDIR:-/tmp}/peach-apk-badging.XXXXXX")93+cleanup_badging() {94+ [ -e "$BADGING_FILE" ] && trash "$BADGING_FILE"95+}96+trap cleanup_badging EXIT HUP INT TERM97+"$AAPT2" dump badging "$APK" > "$BADGING_FILE"98+if ! validate_release_metadata \99+ "$METADATA" \100+ "$BADGING_FILE" \101+ "$ROOT_VERSION" \102+ "com.peachpasswords"; then103+ exit 1104+fi105+106+# apksigner's plain verify accepts v1-only APKs, so require an explicit v2/v3/v4107+# success line before publication. Capture output in memory; it contains only108+# public certificate metadata and is never written with signing secrets.109+SIGNING_OUTPUT=$("$APKSIGNER" verify --verbose --print-certs "$APK" 2>/dev/null) || {110+ echo "refusing to publish: apksigner verification failed" >&2111+ exit 1112+}113+if ! printf '%s\n' "$SIGNING_OUTPUT" | grep -Eq 'Verified using v[234] scheme.*: true'; then114+ echo "refusing to publish: APK is not signed with APK Signature Scheme v2 or newer" >&2115+ exit 1116+fi117+118+PINNED_RELEASE_CERT_SHA256="e10b975178f0bee7fb5a4802519da70ec2988b6ef7d8c9401fbbcff5a1137668"119+CERT_DIGEST_COUNT=$(printf '%s\n' "$SIGNING_OUTPUT" | grep -c 'certificate SHA-256 digest:' || true)120+if [ "$CERT_DIGEST_COUNT" -ne 1 ]; then121+ echo "refusing to publish: release APK must have exactly one signer certificate" >&2122+ exit 1123+fi124+CERT_DIGEST=$(printf '%s\n' "$SIGNING_OUTPUT" |125+ sed -n 's/.*certificate SHA-256 digest:[[:space:]]*//p' |126+ sed -n '1p' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')127+if [ "$CERT_DIGEST" != "$PINNED_RELEASE_CERT_SHA256" ]; then128+ echo "refusing to publish: APK signer certificate does not match the pinned Peach release digest" >&2129+ exit 1130+fi131+132+# Only a fully verified artifact reaches the Kepr staging directory.133+mkdir -p "$ROOT_DIR/build/kepr"134+cp "$APK" "$ROOT_DIR/build/kepr/peach-android.apk"135+echo "release APK verified and staged: build/kepr/peach-android.apk"scripts/kepr-build-helpers.sh+168 −0
@@ -0,0 +1,168 @@1+#!/bin/sh2+# Sourceable, secret-free helpers for the Kepr release gate.3+#4+# This file intentionally contains no signing or Gradle commands. It only5+# selects a stable SDK tool directory and validates public release metadata so6+# the publication script and its hermetic negative-path tests share one7+# implementation.8+9+# Return success only for a stable build-tools directory basename such as10+# "35.0.0". Preview/RC/beta/canary labels, "latest", leading-zero segments,11+# and arbitrary names are rejected before any version sorting occurs.12+is_stable_build_tools_basename() {13+ build_tools_name=$114+ old_ifs=$IFS15+ IFS=.16+ # shellcheck disable=SC208617+ set -- $build_tools_name18+ IFS=$old_ifs19+ [ "$#" -eq 3 ] || return 120+ for segment in "$@"; do21+ case "$segment" in22+ 0|[1-9]|[1-9][0-9]*) ;;23+ *) return 1 ;;24+ esac25+ done26+}27+28+# Print the newest stable build-tools directory containing both tools. Only29+# basenames that passed is_stable_build_tools_basename reach sort -V.30+find_stable_build_tools_dir() {31+ sdk_root=$132+ build_tools_root=$sdk_root/build-tools33+ [ -d "$build_tools_root" ] || return 134+35+ best_name=36+ for candidate in "$build_tools_root"/*; do37+ [ -d "$candidate" ] || continue38+ candidate_name=${candidate##*/}39+ is_stable_build_tools_basename "$candidate_name" || continue40+ [ -x "$candidate/apksigner" ] || continue41+ [ -x "$candidate/aapt2" ] || continue42+43+ if [ -z "$best_name" ] ||44+ [ "$(printf '%s\n' "$best_name" "$candidate_name" | LC_ALL=C sort -V | tail -n 1)" = "$candidate_name" ]; then45+ best_name=$candidate_name46+ fi47+ done48+49+ [ -n "$best_name" ] || return 150+ printf '%s\n' "$build_tools_root/$best_name"51+}52+53+# Read and validate the one-line semantic version authority. Keep this in the54+# shared helper so publication tests exercise the exact same root-file gate.55+read_root_semver() {56+ version_file=$157+ [ -r "$version_file" ] || return 158+ root_version=$(sed -n '1p' "$version_file") || return 159+ [ -n "$root_version" ] || return 160+ is_strict_semver "$root_version" || return 161+ # A second non-empty line would make VERSION ambiguous even if the first62+ # line happened to be valid.63+ extra_lines=$(sed -n '2,$p' "$version_file" | sed '/^[[:space:]]*$/d')64+ [ -z "$extra_lines" ] || return 165+ printf '%s\n' "$root_version"66+}67+68+is_strict_semver() {69+ semantic_version=$170+ case "$semantic_version" in71+ ''|*[!0-9.]* ) return 1 ;;72+ esac73+ old_ifs=$IFS74+ IFS=.75+ # shellcheck disable=SC208676+ set -- $semantic_version77+ IFS=$old_ifs78+ [ "$#" -eq 3 ] || return 179+ for segment in "$@"; do80+ case "$segment" in81+ 0|[1-9]|[1-9][0-9]*) ;;82+ *) return 1 ;;83+ esac84+ done85+}86+87+# Validate Gradle output-metadata.json, the APK's aapt2 badging, and VERSION as88+# one release identity. The Python standard library is used only for parsing89+# generated JSON; duplicate keys and ambiguous element lists are rejected.90+# Arguments: metadata-json badging-file root-version expected-app-id91+validate_release_metadata() {92+ metadata_file=$193+ badging_file=$294+ root_version=$395+ expected_app_id=$496+97+ python3 - "$metadata_file" "$badging_file" "$root_version" "$expected_app_id" <<'PY'98+import json99+import re100+import sys101+102+metadata_path, badging_path, root_version, expected_app_id = sys.argv[1:]103+104+def reject(message):105+ raise SystemExit("release metadata rejected: " + message)106+107+def strict_semver(value):108+ if not isinstance(value, str) or not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", value):109+ reject("version is not strict SemVer")110+ for segment in value.split("."):111+ if int(segment) > 2147483647:112+ reject("version segment is out of range")113+114+class NoDuplicateKeys(dict):115+ def __init__(self, pairs):116+ seen = set()117+ for key, value in pairs:118+ if key in seen:119+ reject("duplicate JSON key: " + key)120+ seen.add(key)121+ self[key] = value122+123+try:124+ with open(metadata_path, encoding="utf-8") as stream:125+ metadata = json.load(stream, object_pairs_hook=NoDuplicateKeys)126+except (OSError, json.JSONDecodeError) as error:127+ reject("malformed Gradle metadata: " + str(error))128+129+if not isinstance(metadata, dict):130+ reject("Gradle metadata root is not an object")131+if metadata.get("applicationId") != expected_app_id:132+ reject("Gradle applicationId does not match production package")133+elements = metadata.get("elements")134+if not isinstance(elements, list) or len(elements) != 1 or not isinstance(elements[0], dict):135+ reject("Gradle metadata must contain exactly one APK element")136+element = elements[0]137+if element.get("versionName") != root_version:138+ reject("Gradle versionName does not match VERSION")139+expected_code = element.get("versionCode")140+if isinstance(expected_code, bool) or not isinstance(expected_code, int) or expected_code < 1:141+ reject("Gradle versionCode is missing or invalid")142+strict_semver(root_version)143+144+try:145+ with open(badging_path, encoding="utf-8") as stream:146+ badging = stream.read()147+except OSError as error:148+ reject("APK badging could not be read: " + str(error))149+150+package_lines = [line for line in badging.splitlines() if line.startswith("package:")]151+if len(package_lines) != 1:152+ reject("APK badging must contain exactly one package line")153+match = re.fullmatch(154+ r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'(?: .*)?",155+ package_lines[0],156+)157+if not match:158+ reject("APK badging package line is malformed")159+apk_app_id, apk_code, apk_version = match.groups()160+if apk_app_id != expected_app_id:161+ reject("APK applicationId does not match Gradle metadata")162+if apk_code != str(expected_code):163+ reject("APK versionCode does not match Gradle metadata")164+if apk_version != root_version:165+ reject("APK versionName does not match VERSION")166+strict_semver(apk_version)167+PY168+}scripts/test-kepr-build-helpers.sh+75 −0
@@ -0,0 +1,75 @@1+#!/bin/sh2+# Hermetic negative-path tests for the sourceable Kepr release-gate helpers.3+# No Android SDK, Gradle project, signing key, network, or APK is required.4+set -eu5+6+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)7+# shellcheck source=scripts/kepr-build-helpers.sh8+. "$SCRIPT_DIR/kepr-build-helpers.sh"9+10+TEMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/peach-kepr-helper-tests.XXXXXX")11+trap 'trash "$TEMP_ROOT"' EXIT HUP INT TERM12+13+fail() {14+ printf 'FAIL: %s\n' "$1" >&215+ exit 116+}17+18+expect_failure() {19+ if "$@"; then20+ fail "expected failure: $*"21+ fi22+}23+24+touch_tools() {25+ mkdir -p "$TEMP_ROOT/sdk/build-tools/$1"26+ : > "$TEMP_ROOT/sdk/build-tools/$1/apksigner"27+ : > "$TEMP_ROOT/sdk/build-tools/$1/aapt2"28+ chmod +x "$TEMP_ROOT/sdk/build-tools/$1/apksigner" "$TEMP_ROOT/sdk/build-tools/$1/aapt2"29+}30+31+touch_tools 34.0.032+touch_tools 35.0.033+touch_tools 35.1.0-preview34+touch_tools 36.0.0-rc135+touch_tools latest36+touch_tools arbitrary37+38+selected=$(find_stable_build_tools_dir "$TEMP_ROOT/sdk")39+[ "$selected" = "$TEMP_ROOT/sdk/build-tools/35.0.0" ] ||40+ fail "stable build-tools selection chose '$selected'"41+for rejected in latest 35.1.0-preview 36.0.0-rc1 arbitrary 35 35.0; do42+ expect_failure is_stable_build_tools_basename "$rejected"43+done44+45+printf '2.8.4\n' > "$TEMP_ROOT/VERSION"46+[ "$(read_root_semver "$TEMP_ROOT/VERSION")" = "2.8.4" ] || fail "valid VERSION was rejected"47+printf '2.8.4\n2.8.5\n' > "$TEMP_ROOT/VERSION.ambiguous"48+expect_failure read_root_semver "$TEMP_ROOT/VERSION.ambiguous"49+printf '2.8\n' > "$TEMP_ROOT/VERSION.malformed"50+expect_failure read_root_semver "$TEMP_ROOT/VERSION.malformed"51+52+cat > "$TEMP_ROOT/metadata.json" <<'JSON'53+{54+ "applicationId": "com.peachpasswords",55+ "elements": [{"versionCode": 240146, "versionName": "2.8.4", "outputFile": "app-release.apk"}]56+}57+JSON58+printf "package: name='com.peachpasswords' versionCode='240146' versionName='2.8.4'\n" > "$TEMP_ROOT/badging"59+validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords60+61+printf '{"applicationId":"com.peachpasswords",' > "$TEMP_ROOT/metadata.malformed"62+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.malformed" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords63+cat > "$TEMP_ROOT/metadata.duplicate.json" <<'JSON'64+{"applicationId":"com.peachpasswords","applicationId":"com.peachpasswords","elements":[{"versionCode":240146,"versionName":"2.8.4"}]}65+JSON66+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.duplicate.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords67+cat > "$TEMP_ROOT/metadata.ambiguous.json" <<'JSON'68+{"applicationId":"com.peachpasswords","elements":[{"versionCode":240146,"versionName":"2.8.4"},{"versionCode":240147,"versionName":"2.8.4"}]}69+JSON70+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.ambiguous.json" "$TEMP_ROOT/badging" 2.8.4 com.peachpasswords71+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging" 2.8.5 com.peachpasswords72+printf "package: name='com.peachpasswords' versionCode='240146' versionName='2.8.5'\n" > "$TEMP_ROOT/badging.mismatch"73+expect_failure validate_release_metadata "$TEMP_ROOT/metadata.json" "$TEMP_ROOT/badging.mismatch" 2.8.4 com.peachpasswords74+75+printf 'PASS: stable tool selection, malformed/ambiguous metadata, and semantic-version mismatch gates\n'