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
↓ peach-android-bf3acf83.tar.gzatlas at this save
files changed5 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 −0app/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)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+}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 /**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+}