bf3acf83a24e

fix: Verify signed update metadata before download (PB-26-028, v2.14.97)

time
2026-09-02 19:46 UTC · 1 month ago
machine
koh2
parent
60e4da33129d
5 files VERSION+1 −1app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+24 −16app/src/main/java/com/peachpasswords/updates/UpdateMetadataSignature.kt+95 −0app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+2 −2app/src/test/java/com/peachpasswords/updates/UpdateMetadataSignatureTest.kt+77 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.961+2.14.97

view file

app/src/main/java/com/peachpasswords/updates/UpdateManifest.kt+24 −16
@@ -24,12 +24,27 @@2424     val checksum: String,2525     /** Absent — not empty, not "null" — when the release carries no notes. */2626     val notes: String?,27+    /** Base64 Ed25519 signature over the canonical release metadata payload. */28+    val signature: String = "",29+    /** Provenance source bound into [signature]. */30+    val provenanceSource: String = "",31+    /** Kepr platform key represented by this flattened manifest. */32+    val platformKey: String = ANDROID_PLATFORM_KEY,2733 ) {2834     fun toJson(): String = JSONObject().apply {2935         put("version", version)30-        put("url", url)31-        put("checksum", checksum)3236         if (notes != null) put("notes", notes)37+        put("platforms", JSONObject().put(platformKey, JSONObject().apply {38+            put("url", url)39+            put(checksum.substringBefore(':'), checksum.substringAfter(':'))40+            put("provenance", JSONObject().apply {41+                put("version", version)42+                put("platform", platformKey)43+                put("checksum", checksum)44+                put("source", provenanceSource)45+            })46+            put("signature", signature)47+        }))3348     }.toString()3449 3550     companion object {@@ -42,13 +57,7 @@4257          * which callers already treat as "no update known".4358          */4459         fun fromCachedJson(json: String): ReleaseManifest? = runCatching {45-            val o = JSONObject(json)46-            ReleaseManifest(47-                version = o.getString("version").also(::validateSemver),48-                url = o.getString("url"),49-                checksum = o.getString("checksum"),50-                notes = o.optTextOrNull("notes"),51-            )60+            parseSignedReleaseManifest(json)5261         }.getOrNull()5362     }5463 }@@ -188,12 +197,11 @@188197  *   would render as changelog text. [optTextOrNull] rejects that class of value189198  *   outright rather than trusting the caller to notice.190199  *191- * Security note (H-08): the canonical platform key is required and the192- * sole-artifact fallback is gone. A Kepr build that only published, say,193- * `android-x86` would have been accepted before and pointed an Android phone at194- * the wrong binary; now it is rejected outright. A signed release-manifest195- * channel is the real fix — that is an architectural change and is out of scope196- * for H-08.200+ * Security note: the canonical platform key is required and the sole-artifact201+ * fallback is gone. A Kepr build that only published, say, `android-x86` would202+ * otherwise point an Android phone at the wrong binary; signed metadata is now203+ * verified by [parseSignedReleaseManifest] before this parser is used by the204+ * update network path.197205  */198206 fun parseReleaseManifest(body: String, platformKey: String = ANDROID_PLATFORM_KEY): ReleaseManifest {199207     val root = runCatching { JSONObject(body) }@@ -232,7 +240,7 @@232240  * The string is normalized to `<algorithm>:<64 lowercase hex>` so233241  * [parseChecksum] downstream can validate it.234242  */235-private fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {243+internal fun parseArtifactChecksum(artifact: org.json.JSONObject): String? {236244     val blake3 = artifact.optTextOrNull("blake3")?.takeIf(::isHex64)237245     if (blake3 != null) return "blake3:$blake3"238246     val sha256 = artifact.optTextOrNull("sha256")?.takeIf(::isHex64)

view file

app/src/main/java/com/peachpasswords/updates/UpdateMetadataSignature.kt+95 −0
@@ -0,0 +1,95 @@1+package com.peachpasswords.updates2+3+import java.util.Base644+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters5+import org.bouncycastle.crypto.signers.Ed25519Signer6+import org.json.JSONObject7+8+/**9+ * Public half of the Peach-vault-backed release metadata key. The private seed10+ * is never shipped or read by the app. An empty value deliberately fails closed11+ * until the owner provisions the generated public key in the release source.12+ */13+const val UPDATE_METADATA_PUBLIC_KEY_HEX = "d6a47f867d2add9c6490447c7a611c08a474a98ab7afa540f65d4347f26ba77c"14+15+internal fun canonicalReleaseMetadataPayload(16+    version: String,17+    platform: String,18+    url: String,19+    checksumHex: String,20+    provenanceSource: String,21+): ByteArray = "$version\n$platform\n$url\n$checksumHex\n$version\n$platform\n$checksumHex\n$provenanceSource"22+    .toByteArray(Charsets.UTF_8)23+24+private fun decodeHex(value: String): ByteArray? {25+    if (value.length != 64 || value.any { it !in "0123456789abcdefABCDEF" }) return null26+    return ByteArray(32) { i ->27+        value.substring(i * 2, i * 2 + 2).toInt(16).toByte()28+    }29+}30+31+private fun verifySignature(publicKeyHex: String, payload: ByteArray, encoded: String): Boolean {32+    val key = decodeHex(publicKeyHex) ?: return false33+    val signature = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return false34+    if (signature.size != 64) return false35+    return runCatching {36+        Ed25519Signer().apply {37+            init(false, Ed25519PublicKeyParameters(key, 0))38+            update(payload, 0, payload.size)39+        }.verifySignature(signature)40+    }.getOrDefault(false)41+}42+43+/**44+ * Authenticates the platform object before [parseReleaseManifest] trusts its45+ * URL, checksum, version, or notes. JSON is inspected only to locate the46+ * signed fields; all semantic validation remains in the existing parser.47+ */48+fun parseSignedReleaseManifest(49+    body: String,50+    platformKey: String = ANDROID_PLATFORM_KEY,51+    publicKeyHex: String = UPDATE_METADATA_PUBLIC_KEY_HEX,52+): ReleaseManifest {53+    val root = runCatching { JSONObject(body) }54+        .getOrElse { throw ManifestFormatException("manifest is not JSON") }55+    val version = root.optTextOrNull("version")56+        ?: throw ManifestFormatException("signed manifest has no version")57+    val platform = root.optJSONObject("platforms")?.optJSONObject(platformKey)58+        ?: throw ManifestFormatException("signed manifest has no '$platformKey' artifact")59+    val url = platform.optTextOrNull("url")60+        ?: throw ManifestFormatException("signed artifact has no url")61+    val checksum = parseArtifactChecksum(platform)62+        ?: throw ManifestFormatException("signed artifact has no checksum")63+    val checksumParts = checksum.split(':', limit = 2)64+    val checksumHex = checksumParts.getOrNull(1)65+        ?: throw ManifestFormatException("signed artifact checksum is malformed")66+    val provenance = platform.optJSONObject("provenance")67+        ?: throw ManifestFormatException("signed artifact has no provenance")68+    val provenanceVersion = provenance.optTextOrNull("version")69+        ?: throw ManifestFormatException("signed artifact provenance has no version")70+    val provenancePlatform = provenance.optTextOrNull("platform")71+        ?: throw ManifestFormatException("signed artifact provenance has no platform")72+    val provenanceChecksum = provenance.optTextOrNull("checksum")73+        ?: throw ManifestFormatException("signed artifact provenance has no checksum")74+    val source = provenance.optTextOrNull("source")75+        ?: throw ManifestFormatException("signed artifact provenance has no source")76+    val signature = platform.optTextOrNull("signature")77+        ?: throw ManifestFormatException("signed artifact has no signature")78+    if (provenanceVersion != version || provenancePlatform != platformKey ||79+        provenanceChecksum != checksum || checksumParts[0] != "blake3") {80+        throw ManifestFormatException("signed artifact provenance does not match manifest")81+    }82+    if (!verifySignature(83+            publicKeyHex,84+            canonicalReleaseMetadataPayload(version, platformKey, url, checksumHex, source),85+            signature,86+        )87+    ) {88+        throw ManifestFormatException("release metadata signature verification failed")89+    }90+    return parseReleaseManifest(body, platformKey).copy(91+        signature = signature,92+        provenanceSource = source,93+        platformKey = platformKey,94+    )95+}

view file

app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+2 −2
@@ -120,7 +120,7 @@120120             if (!response.isSuccessful) throw IOException("HTTP ${response.code}")121121             val platform = androidPlatformKey(Build.SUPPORTED_ABIS)122122                 ?: throw ManifestFormatException("this Android ABI is not supported")123-            parseReleaseManifest(response.body?.string().orEmpty(), platform)123+            parseSignedReleaseManifest(response.body?.string().orEmpty(), platform)124124         }125125     }126126 @@ -402,7 +402,7 @@402402 403403     private fun cachedAvailable(): ReleaseManifest? =404404         prefs.getString(KEY_LATEST_MANIFEST, null)405-            ?.let { ReleaseManifest.fromCachedJson(it) }405+            ?.let { runCatching { parseSignedReleaseManifest(it, androidPlatformKey(Build.SUPPORTED_ABIS) ?: ANDROID_PLATFORM_KEY) }.getOrNull() }406406             ?.takeIf { isNewerVersion(it.version, currentVersion) }407407 408408     /**

view file

app/src/test/java/com/peachpasswords/updates/UpdateMetadataSignatureTest.kt+77 −0
@@ -0,0 +1,77 @@1+package com.peachpasswords.updates2+3+import java.util.Base644+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters5+import org.bouncycastle.crypto.signers.Ed25519Signer6+import org.junit.Assert.assertEquals7+import org.junit.Assert.assertThrows8+import org.junit.Test9+import com.peachpasswords.sync.verifyEd2551910+import com.peachpasswords.sync.hexToBytes11+12+class UpdateMetadataSignatureTest {13+    private val seed = ByteArray(32) { 7 }14+    private val privateKey = Ed25519PrivateKeyParameters(seed, 0)15+    private val publicKeyHex = privateKey.generatePublicKey().encoded.joinToString("") { "%02x".format(it) }16+    private val version = "2.14.97"17+    private val platform = ANDROID_PLATFORM_KEY18+    private val url = "https://kepr.uk/peach-android/releases/$version/peach-android-$platform"19+    private val checksum = "a".repeat(64)20+21+    private fun signedBody(22+        signatureOverride: String? = null,23+        urlOverride: String = url,24+        signingUrl: String = urlOverride,25+    ): String {26+        val payload = canonicalReleaseMetadataPayload(version, platform, signingUrl, checksum, "koh-offer")27+        val signer = Ed25519Signer().apply {28+            init(true, privateKey)29+            update(payload, 0, payload.size)30+        }31+        val signature = signatureOverride ?: Base64.getEncoder().encodeToString(signer.generateSignature())32+        return """33+            {"version":"$version","platforms":{"$platform":{34+              "url":"$urlOverride","blake3":"$checksum",35+              "provenance":{"version":"$version","platform":"$platform","checksum":"blake3:$checksum","source":"koh-offer"},36+              "signature":"$signature"}}}37+        """.trimIndent()38+    }39+40+    @Test41+    fun `matching signature is required before manifest is accepted`() {42+        val payload = canonicalReleaseMetadataPayload(version, platform, url, checksum, "koh-offer")43+        val rawSignature = Ed25519Signer().apply {44+            init(true, privateKey)45+            update(payload, 0, payload.size)46+        }.generateSignature()47+        assertEquals(true, verifyEd25519(privateKey.generatePublicKey().encoded, payload, rawSignature))48+        assertEquals(64, publicKeyHex.length)49+        val encoded = Base64.getEncoder().encodeToString(rawSignature)50+        assertEquals(true, verifyEd25519(hexToBytes(publicKeyHex), payload, Base64.getDecoder().decode(encoded)))51+        val parsed = parseSignedReleaseManifest(signedBody(signatureOverride = encoded), publicKeyHex = publicKeyHex)52+        assertEquals(url, parsed.url)53+        assertEquals("koh-offer", parsed.provenanceSource)54+    }55+56+    @Test57+    fun `unsigned metadata is rejected`() {58+        assertThrows(ManifestFormatException::class.java) {59+            parseSignedReleaseManifest(signedBody(signatureOverride = ""), publicKeyHex = publicKeyHex)60+        }61+    }62+63+    @Test64+    fun `alternate URL under a valid checksum is rejected`() {65+        val body = signedBody(urlOverride = "https://evil.invalid/release.apk", signingUrl = url)66+        assertThrows(ManifestFormatException::class.java) {67+            parseSignedReleaseManifest(body, publicKeyHex = publicKeyHex)68+        }69+    }70+71+    @Test72+    fun `unknown key is rejected`() {73+        assertThrows(ManifestFormatException::class.java) {74+            parseSignedReleaseManifest(signedBody(), publicKeyHex = "01".repeat(32))75+        }76+    }77+}

view file