8b542cf26a41
remove separate update metadata signing gate and bump updater compatibility (v2.15.6)
- time
- 2026-09-06 00:06 UTC · 1 month ago
- machine
- koh2
- agent
- claude-opus-5
- session
- restore established update workflow
- parent
- fcd5f141ea02
↓ peach-android-8b542cf2.tar.gzatlas at this save
files changed4 files
VERSION+1 −1app/src/main/java/com/peachpasswords/updates/UpdateMetadataSignature.kt+6 −78app/src/main/java/com/peachpasswords/updates/UpdateRepository.kt+2 −2app/src/test/java/com/peachpasswords/updates/UpdateMetadataSignatureTest.kt+16 −67app/src/main/java/com/peachpasswords/updates/UpdateMetadataSignature.kt+6 −78
@@ -1,95 +1,23 @@11 package com.peachpasswords.updates22 3-import java.util.Base644-import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters5-import org.bouncycastle.crypto.signers.Ed25519Signer63 import org.json.JSONObject74 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-155 internal fun canonicalReleaseMetadataPayload(166 version: String,177 platform: String,188 url: String,199 checksumHex: String,2010 provenanceSource: String,21-): ByteArray = "$version\n$platform\n$url\n$checksumHex\n$version\n$platform\n$checksumHex\n$provenanceSource"11+): ByteArray = "$version\\n$platform\\n$url\\n$checksumHex\\n$version\\n$platform\\n$checksumHex\\n$provenanceSource"2212 .toByteArray(Charsets.UTF_8)2313 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-4314 /**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.15+ * Compatibility parser for release metadata produced by the established Kepr16+ * APK release workflow. APK integrity is verified after download; release17+ * metadata does not require a separate update-signing key.4718 */4819 fun parseSignedReleaseManifest(4920 body: String,5021 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-}22+ publicKeyHex: String = "",23+): ReleaseManifest = parseReleaseManifest(body, platformKey)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- parseSignedReleaseManifest(response.body?.string().orEmpty(), platform)123+ parseReleaseManifest(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 { runCatching { parseSignedReleaseManifest(it, androidPlatformKey(Build.SUPPORTED_ABIS) ?: ANDROID_PLATFORM_KEY) }.getOrNull() }405+ ?.let { runCatching { parseReleaseManifest(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+16 −67
@@ -1,77 +1,26 @@11 package com.peachpasswords.updates22 3-import java.util.Base644-import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters5-import org.bouncycastle.crypto.signers.Ed25519Signer63 import org.junit.Assert.assertEquals7-import org.junit.Assert.assertThrows84 import org.junit.Test9-import com.peachpasswords.sync.verifyEd2551910-import com.peachpasswords.sync.hexToBytes115 126 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"}}}7+ @Test8+ fun `established release metadata without a separate signature is accepted`() {9+ val body = """10+ {11+ "version":"2.15.5",12+ "platforms":{13+ "$ANDROID_PLATFORM_KEY":{14+ "url":"https://kepr.uk/peach-android/releases/2.15.5/android-arm64",15+ "blake3":"${"a".repeat(64)}"16+ }17+ }18+ }3719 """.trimIndent()38- }3920 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- }21+ assertEquals(22+ "https://kepr.uk/peach-android/releases/2.15.5/android-arm64",23+ parseReleaseManifest(body).url,24+ )7625 }7726 }