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
8 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.171+2.8.4

view file

app/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 {

view file

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 /**

view file

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() {

view file

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 }

view file

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"

view file

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+}

view file

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'

view file