From 3f3e61b446111ba564a7888cda7592f0b41a0a9c Mon Sep 17 00:00:00 2001 From: David Date: Wed, 30 Sep 2026 00:33:57 -0700 Subject: [PATCH] fix(#62): verify display-service signers from the APK Signing Block On the NP02J/K68 (Android 13) 0.4.0 downloaded the pinned display services (size + sha256 OK) and then refused device-service: "Android could not read the signing certificate". Both service APKs are signed with APK Signature Scheme v2 only, and their certificates are the pinned ones. Root cause is the platform: in android13-release, PackageManager.getPackageArchiveInfo() calls collectCertificates() only when the flags contain the deprecated GET_SIGNATURES. GET_SIGNING_CERTIFICATES alone leaves SigningDetails.UNKNOWN and generatePackageInfo() returns signingInfo = null. Android 12 (ApplicationPackageManager) and 13 QPR1+ test either flag, which is why this only shows on some framework builds. Fix: - ApkSignatureReader: a pure-Kotlin v2/v3/v3.1 verifier. Finds the EOCD, the APK Signing Block, picks the scheme the platform would use for the SDK (v3.1 -> v3 -> v2), verifies the signer's signature over its signed data, that the public key is the certificate's, and the whole-file content digest; returns the SHA-256 of each signer certificate. - archiveIdentity also asks Android with GET_SIGNATURES | GET_SIGNING_CERTIFICATES and DisplayServices.combineSigners() cross-checks the two: a block that does not verify, or a disagreement, is refused; v1-only falls back to Android. archiveProblem still requires exactly the pinned (== manifest) signer. - Tests sign synthetic APKs at test time with apksig + bouncycastle (v1-only, v2-only, ECDSA, v2+v3, rotation via v3 and v3.1, two signers, tampered contents / central directory / certificate, truncated, unsigned). Every guard was mutation-checked. The real vendor APKs are checked when DXR_SERVICE_APK_DIR is set locally (never committed). - installer 0.4.1 (versionCode 6). Co-Authored-By: Claude Opus 5.5 --- android-installer/README.md | 11 +- android-installer/app/build.gradle.kts | 6 + .../com/displayxr/installer/ApkInstaller.kt | 55 ++- .../displayxr/installer/ApkSignatureReader.kt | 362 ++++++++++++++++++ .../displayxr/installer/DisplayServices.kt | 59 ++- .../displayxr/installer/InstallerViewModel.kt | 4 +- .../installer/ApkSignatureReaderTest.kt | 316 +++++++++++++++ android-installer/gradle.properties | 8 +- 8 files changed, 799 insertions(+), 22 deletions(-) create mode 100644 android-installer/app/src/main/java/com/displayxr/installer/ApkSignatureReader.kt create mode 100644 android-installer/app/src/test/java/com/displayxr/installer/ApkSignatureReaderTest.kt diff --git a/android-installer/README.md b/android-installer/README.md index 547a535..3638342 100644 --- a/android-installer/README.md +++ b/android-installer/README.md @@ -71,8 +71,14 @@ refuses, with a sentence on the row: app** (`DisplayServices.PINNED_SIGNERS`: device-service `dbc2792f…811c`, head tracking `113ec052…5096`) — before a byte is downloaded; - a download whose size or sha256 is not the manifest's (deleted, never handed to Android); -- an archive whose package, versionCode or **current signing certificate** (read by Android from - the file) is not the manifest's and the pin's. +- an archive whose package, versionCode or **current signing certificate** is not the manifest's + and the pin's. The certificate is read **and verified by the app itself** from the APK Signing + Block (`ApkSignatureReader`: v3.1 → v3 → v2, the signer's signature over its signed data and the + whole-file content digest, so a forged or modified file is refused), and cross-checked against + `getPackageArchiveInfo`; if the two disagree, or the block does not verify, it is refused. Android's + answer is not enough on its own: the initial Android 13 framework (the NP02J/K68's) collects an + archive's certificates only for the deprecated `GET_SIGNATURES` flag, so 0.4.0, which asked with + `GET_SIGNING_CERTIFICATES`, got no certificate for the v2-only vendor services and refused them. And Android itself refuses an update to a built-in app not signed with that app's key. The pin in the app and the pin the publisher enforces (`scripts/service-signers.tsv`) are one table in two @@ -399,6 +405,7 @@ android-installer/ ├── Net.kt HTTP + every typed failure the UI can show ├── GitHubReleases.kt versions.json + pin -> release asset ├── ApkInstaller.kt PackageInstaller sessions and their verdicts; archive identity + ├── ApkSignatureReader.kt v2/v3/v3.1 signing-block verifier: the archive's signer certificate (pure, JVM-tested) ├── InstallerViewModel.kt the run: order, the services, the browser gate, launch-once, keyguard └── MainActivity.kt the screen ``` diff --git a/android-installer/app/build.gradle.kts b/android-installer/app/build.gradle.kts index a472277..92a1753 100644 --- a/android-installer/app/build.gradle.kts +++ b/android-installer/app/build.gradle.kts @@ -73,4 +73,10 @@ dependencies { // The real org.json for JVM tests: android.jar's copy is a stub that throws, and the // services manifest parser is exactly the kind of decision these tests exist for. testImplementation("org.json:json:20240303") + // Signs synthetic APKs AT TEST TIME (v1-only, v2-only, v2+v3, key rotation, two signers) + // so ApkSignatureReader is proven on real signing blocks without a single vendor byte or + // a checked-in key in the repo. apksig is the library apksigner itself is built on; + // bcpkix only mints the throwaway self-signed certificates. + testImplementation("com.android.tools.build:apksig:8.7.3") + testImplementation("org.bouncycastle:bcpkix-jdk18on:1.77") } diff --git a/android-installer/app/src/main/java/com/displayxr/installer/ApkInstaller.kt b/android-installer/app/src/main/java/com/displayxr/installer/ApkInstaller.kt index 7723955..d8901d6 100644 --- a/android-installer/app/src/main/java/com/displayxr/installer/ApkInstaller.kt +++ b/android-installer/app/src/main/java/com/displayxr/installer/ApkInstaller.kt @@ -14,6 +14,16 @@ import kotlinx.coroutines.withTimeoutOrNull import java.io.File import java.io.IOException +/** What [ApkInstaller.archiveIdentity] read out of a downloaded APK. */ +data class ArchiveIdentity( + val packageName: String?, + val versionCode: Long?, + /** SHA-256 of the current signer certificate(s); empty when none could be verified. */ + val signers: List, + /** Why [signers] is empty, when known. */ + val signerProblem: String?, +) + sealed class InstallOutcome { object Success : InstallOutcome() @@ -239,23 +249,40 @@ object ApkInstaller { context.packageManager.getPackageArchiveInfo(apk.absolutePath, 0)?.packageName /** - * Package name, versionCode and the SHA-256 of the CURRENT signing certificate(s) - * of a downloaded archive — the inputs of [DisplayServices.archiveProblem]. + * Package name, versionCode and the SHA-256 of the CURRENT signing certificate(s) of a + * downloaded archive — the inputs of [DisplayServices.archiveProblem]. + * + * The certificate is read twice, independently, and combined by + * [DisplayServices.combineSigners]: + * + * 1. [ApkSignatureReader] verifies the APK Signing Block (v2/v3/v3.1) itself. This is + * the reading that matters for the display services, which are v2-signed only. + * 2. `getPackageArchiveInfo`. The flags carry the deprecated `GET_SIGNATURES` as well + * as `GET_SIGNING_CERTIFICATES` ON PURPOSE: the initial Android 13 framework + * (`android13-release`, the NP02J/K68's) collects certificates for an archive only + * when `GET_SIGNATURES` is set, so `GET_SIGNING_CERTIFICATES` alone returned a null + * `signingInfo` there and 0.4.0 refused the tablet's own vendor update. Android 12 + * and 13 QPR1+ accept either flag. * - * Current, not historical: with a single signer, `signingCertificateHistory` lists - * the rotation lineage oldest-first and its LAST entry is the key the APK is signed - * with now; with several signers, `apkContentsSigners` is the set. Anything Android - * cannot read comes back empty, and the caller refuses the archive on that. + * Current, not historical: with a single signer, `signingCertificateHistory` lists the + * rotation lineage oldest-first and its LAST entry is the key the APK is signed with + * now; with several signers, `apkContentsSigners` is the set. The reader likewise + * prefers v3.1/v3 (the rotated key) over v2. + * + * And behind both: Android refuses an update to a system app that is not signed with + * that app's key, so a substituted service could not install anyway. This check stays + * because it turns that into a sentence on the row, and refuses before the confirmation. */ - fun archiveIdentity(context: Context, apk: File): Triple> { + fun archiveIdentity(context: Context, apk: File): ArchiveIdentity { + @Suppress("DEPRECATION") val info = try { context.packageManager.getPackageArchiveInfo( apk.absolutePath, - PackageManager.GET_SIGNING_CERTIFICATES, + PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES, ) } catch (t: Throwable) { null - } ?: return Triple(null, null, emptyList()) + } ?: return ArchiveIdentity(null, null, emptyList(), null) val si = info.signingInfo val sigs = when { si == null -> emptyArray() @@ -263,8 +290,14 @@ object ApkInstaller { else -> si.signingCertificateHistory?.takeLast(1)?.toTypedArray() ?: emptyArray() } val md = { b: ByteArray -> java.security.MessageDigest.getInstance("SHA-256").digest(b) } - val digests = sigs.map { s -> md(s.toByteArray()).joinToString("") { "%02x".format(it) } } - return Triple(info.packageName, info.longVersionCode, digests) + val platform = sigs.map { s -> md(s.toByteArray()).joinToString("") { "%02x".format(it) } } + val own = try { + ApkSignatureReader.read(apk, Build.VERSION.SDK_INT) + } catch (t: Throwable) { + ApkSignatureReader.Result.Invalid("the signing block could not be read (${t.javaClass.simpleName})") + } + val reading = DisplayServices.combineSigners(own, platform) + return ArchiveIdentity(info.packageName, info.longVersionCode, reading.signers, reading.problem) } /** diff --git a/android-installer/app/src/main/java/com/displayxr/installer/ApkSignatureReader.kt b/android-installer/app/src/main/java/com/displayxr/installer/ApkSignatureReader.kt new file mode 100644 index 0000000..3a5c439 --- /dev/null +++ b/android-installer/app/src/main/java/com/displayxr/installer/ApkSignatureReader.kt @@ -0,0 +1,362 @@ +package com.displayxr.installer + +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.PublicKey +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.MGF1ParameterSpec +import java.security.spec.PSSParameterSpec +import java.security.spec.X509EncodedKeySpec + +/** + * Reads AND verifies the signer certificate(s) of an APK from its APK Signing Block + * (APK Signature Scheme v2 / v3 / v3.1) — without asking Android. + * + * Why it exists: on the NP02J/K68 (Android 13, API 33) a display-service APK signed with + * v2 only came back from `getPackageArchiveInfo(path, GET_SIGNING_CERTIFICATES)` with a + * null `signingInfo`, and the installer refused the very update the tablet needed. The + * cause is in the platform, not the file: in `android13-release` (the initial Android 13 + * framework) `PackageManager.getPackageArchiveInfo` calls `PackageParser.collectCertificates` + * only when the flags contain the DEPRECATED `GET_SIGNATURES`; `GET_SIGNING_CERTIFICATES` + * alone leaves `SigningDetails.UNKNOWN`, and `generatePackageInfo` turns that into + * `signingInfo = null`. Android 12's `ApplicationPackageManager` and `android13-qpr1` onward + * test either flag. So the answer depends on the tablet's framework build — the reason the + * installer no longer rests this check on `getPackageArchiveInfo` alone. + * + * This is a verifier, not just a parser: a certificate pasted into a forged signing block + * without the key must not read as "signed by the vendor". For the signer(s) the platform + * would use on [sdkInt] it checks, like `ApkSignatureSchemeV2Verifier`/`V3Verifier`: + * - the signature over the signed data, with the signer's public key; + * - that public key equals the first certificate's; + * - the signature algorithms and the digest algorithms list the same IDs; + * - the whole-file content digest (1 MiB chunks over the three ZIP sections) equals the + * signed one — so a single flipped byte anywhere in the APK is refused. + * + * Scheme choice mirrors the platform: v3.1 when it has a signer for [sdkInt] (key rotation + * targeting API 33+), else v3 when it has one, else v2. v3 carries the CURRENT key after a + * rotation, v2 the original one, so the certificate returned is the one Android will + * compare against the installed app's key. v1 (JAR) signatures are not read here: an APK + * with no signing block is [Result.NoSigningBlock] and the caller falls back to Android. + * + * Pure JVM (java.io + java.security), so it is covered by JVM tests on real signed APKs. + */ +object ApkSignatureReader { + + sealed class Result { + /** @param certSha256 lowercase hex SHA-256 of each verified signer's certificate (DER as stored). */ + data class Verified(val scheme: String, val certSha256: List) : Result() + + /** No APK Signing Block, or none of v2/v3/v3.1 in it (e.g. a v1-only or unsigned APK). */ + data class NoSigningBlock(val why: String) : Result() + + /** Signed with only algorithms this reader does not implement; Android must decide. */ + data class Unsupported(val why: String) : Result() + + /** A signing block that does not verify: tampered, truncated or forged. Refuse. */ + data class Invalid(val why: String) : Result() + } + + const val V2_BLOCK_ID = 0x7109871a + const val V3_BLOCK_ID = 0xf05368c0.toInt() + const val V31_BLOCK_ID = 0x1b93ad61 + + private const val EOCD_MAGIC = 0x06054b50 + private const val EOCD_MIN = 22 + private const val MAGIC_LO = 0x20676953204b5041L // "APK Sig " + private const val MAGIC_HI = 0x3234206b636f6c42L // "Block 42" + private const val CHUNK = 1024 * 1024 + + private class Malformed(msg: String) : Exception(msg) + + fun read(apk: File, sdkInt: Int): Result = try { + RandomAccessFile(apk, "r").use { readImpl(it, sdkInt) } + } catch (e: Malformed) { + Result.Invalid(e.message ?: "malformed signing block") + } catch (e: IOException) { + Result.Invalid("could not read the file (${e.message})") + } + + // ------------------------------------------------------------------ ZIP + block + + private class Layout(val sigBlockStart: Long, val cdOffset: Long, val eocdOffset: Long, val eocd: ByteArray) + + private fun readImpl(f: RandomAccessFile, sdkInt: Int): Result { + val len = f.length() + if (len < EOCD_MIN) return Result.NoSigningBlock("not a ZIP archive") + // EOCD: scan back over at most a 65535-byte comment. + val tailLen = minOf(len, (EOCD_MIN + 0xffff).toLong()).toInt() + val tail = ByteArray(tailLen).also { f.seek(len - tailLen); f.readFully(it) } + val tb = ByteBuffer.wrap(tail).order(ByteOrder.LITTLE_ENDIAN) + var eocdPos = -1 + var i = tailLen - EOCD_MIN + while (i >= 0) { + if (tb.getInt(i) == EOCD_MAGIC && (tb.getShort(i + 20).toInt() and 0xffff) == tailLen - i - EOCD_MIN) { + eocdPos = i; break + } + i-- + } + if (eocdPos < 0) return Result.NoSigningBlock("not a ZIP archive (no end-of-central-directory record)") + val eocdOffset = len - tailLen + eocdPos + val eocd = tail.copyOfRange(eocdPos, tailLen) + val cdOffset = tb.getInt(eocdPos + 16).toLong() and 0xffffffffL + val cdSize = tb.getInt(eocdPos + 12).toLong() and 0xffffffffL + if (cdOffset + cdSize != eocdOffset) throw Malformed("the central directory does not end at the end-of-central-directory record") + if (cdOffset < 32) return Result.NoSigningBlock("no APK Signing Block") + + // Footer: u64 size, then the 16-byte magic, immediately before the central directory. + val footer = ByteArray(24).also { f.seek(cdOffset - 24); f.readFully(it) } + val fb = ByteBuffer.wrap(footer).order(ByteOrder.LITTLE_ENDIAN) + if (fb.getLong(8) != MAGIC_LO || fb.getLong(16) != MAGIC_HI) { + return Result.NoSigningBlock("no APK Signing Block (v1-signed or unsigned)") + } + val blockSize = fb.getLong(0) + if (blockSize < 24 || blockSize > Int.MAX_VALUE - 8) throw Malformed("APK Signing Block size $blockSize is out of range") + val sigBlockStart = cdOffset - blockSize - 8 + if (sigBlockStart < 0) throw Malformed("APK Signing Block starts before the file") + val block = ByteArray((blockSize + 8).toInt()).also { f.seek(sigBlockStart); f.readFully(it) } + val bb = ByteBuffer.wrap(block).order(ByteOrder.LITTLE_ENDIAN) + if (bb.getLong(0) != blockSize) throw Malformed("APK Signing Block sizes disagree") + + // id-value pairs between the leading size and the footer. + val pairs = HashMap() + val pairsEnd = block.size - 24 + var p = 8 + while (p < pairsEnd) { + if (pairsEnd - p < 8) throw Malformed("truncated id-value pair") + val pl = bb.getLong(p) + if (pl < 4 || pl > pairsEnd - p - 8) throw Malformed("id-value pair length $pl is out of range") + val id = bb.getInt(p + 8) + pairs.putIfAbsent(id, slice(bb, p + 12, (pl - 4).toInt())) + p += 8 + pl.toInt() + } + + val layout = Layout(sigBlockStart, cdOffset, eocdOffset, eocd) + val candidates = listOf( + Triple(V31_BLOCK_ID, "v3.1", true), + Triple(V3_BLOCK_ID, "v3", true), + Triple(V2_BLOCK_ID, "v2", false), + ) + for ((id, name, isV3) in candidates) { + val value = pairs[id] ?: continue + val signers = parseSigners(value, isV3, sdkInt) + if (signers.isEmpty()) continue // v3/v3.1 with no signer for this SDK: the platform falls through + if (isV3 && signers.size != 1) throw Malformed("$name has ${signers.size} signers for API $sdkInt") + return verify(f, layout, name, signers) + } + return Result.NoSigningBlock("the APK Signing Block has no v2/v3 signature") + } + + // ------------------------------------------------------------------ signers + + private class SignerRec( + val signedData: ByteArray, + val digests: Map, + val certs: List, + val signatures: List>, + val publicKey: ByteArray, + ) + + private fun parseSigners(value: ByteBuffer, isV3: Boolean, sdkInt: Int): List { + val signersSeq = lp(value.duplicate().order(ByteOrder.LITTLE_ENDIAN)) + val out = ArrayList() + var count = 0 + while (signersSeq.hasRemaining()) { + count++ + val signer = lp(signersSeq) + val signedDataBuf = lp(signer) + val signedData = bytes(signedDataBuf.duplicate()) + if (isV3) { + val minSdk = u32(signer); val maxSdk = u32(signer) + if (sdkInt < minSdk || sdkInt > maxSdk) { + // Parse the rest only to stay aligned; this signer is for another SDK. + lp(signer); lp(signer) + continue + } + } + val sigsSeq = lp(signer) + val publicKey = bytes(lp(signer)) + + val sd = signedDataBuf.duplicate().order(ByteOrder.LITTLE_ENDIAN) + val digestsSeq = lp(sd) + val certsSeq = lp(sd) + val digests = LinkedHashMap() + while (digestsSeq.hasRemaining()) { + val d = lp(digestsSeq); val alg = d.int; digests[alg] = bytes(lp(d)) + } + val certs = ArrayList() + while (certsSeq.hasRemaining()) certs.add(bytes(lp(certsSeq))) + if (isV3) { + val sdMin = u32(sd); val sdMax = u32(sd) + if (sdMin > sdkInt || sdMax < sdkInt) throw Malformed("v3 signed data SDK range disagrees with the signer's") + } + val sigs = ArrayList>() + while (sigsSeq.hasRemaining()) { + val s = lp(sigsSeq); val alg = s.int; sigs.add(alg to bytes(lp(s))) + } + out.add(SignerRec(signedData, digests, certs, sigs, publicKey)) + } + if (count == 0) throw Malformed("a signature scheme block with no signers") + return out + } + + private enum class Alg(val digest: String, val strength: Int) { SHA256("SHA-256", 1), SHA512("SHA-512", 2) } + + private fun contentDigestOf(alg: Int): Alg? = when (alg) { + 0x0101, 0x0103, 0x0201, 0x0301 -> Alg.SHA256 + 0x0102, 0x0104, 0x0202 -> Alg.SHA512 + else -> null // incl. the verity variants 0x0421/0x0423/0x0425 + } + + private fun verifySignature(alg: Int, key: PublicKey, data: ByteArray, sig: ByteArray): Boolean { + val s: Signature = when (alg) { + 0x0101 -> pss("SHA-256", MGF1ParameterSpec.SHA256, 32) + 0x0102 -> pss("SHA-512", MGF1ParameterSpec.SHA512, 64) + 0x0103 -> Signature.getInstance("SHA256withRSA") + 0x0104 -> Signature.getInstance("SHA512withRSA") + 0x0201 -> Signature.getInstance("SHA256withECDSA") + 0x0202 -> Signature.getInstance("SHA512withECDSA") + 0x0301 -> Signature.getInstance("SHA256withDSA") + else -> return false + } + s.initVerify(key) + s.update(data) + return s.verify(sig) + } + + private fun pss(md: String, mgf: MGF1ParameterSpec, salt: Int): Signature { + val spec = PSSParameterSpec(md, "MGF1", mgf, salt, 1) + // Android (Conscrypt) names it "SHA256withRSA/PSS"; the JDK "RSASSA-PSS". + return try { + Signature.getInstance(md.replace("-", "") + "withRSA/PSS").also { it.setParameter(spec) } + } catch (e: Exception) { + Signature.getInstance("RSASSA-PSS").also { it.setParameter(spec) } + } + } + + private fun publicKeyOf(encoded: ByteArray): PublicKey { + for (kf in listOf("RSA", "EC", "DSA")) { + try { + return KeyFactory.getInstance(kf).generatePublic(X509EncodedKeySpec(encoded)) + } catch (e: Exception) { /* next */ } + } + throw Malformed("a signer's public key is not RSA, EC or DSA") + } + + private fun verify(f: RandomAccessFile, layout: Layout, scheme: String, signers: List): Result { + val wanted = HashMap() // content digest each signer's strongest algorithm commits to + val certDigests = ArrayList() + for (s in signers) { + if (s.signatures.isEmpty()) throw Malformed("$scheme signer has no signatures") + if (s.certs.isEmpty()) throw Malformed("$scheme signer has no certificate") + if (s.signatures.map { it.first } != s.digests.keys.toList()) { + throw Malformed("$scheme signature and digest algorithm lists differ") + } + val best = s.signatures.filter { contentDigestOf(it.first) != null } + .maxByOrNull { contentDigestOf(it.first)!!.strength } + ?: return Result.Unsupported("$scheme signer uses only signature algorithms " + + s.signatures.joinToString { "0x%04x".format(it.first) } + " this installer does not verify") + val key = publicKeyOf(s.publicKey) + val ok = try { verifySignature(best.first, key, s.signedData, best.second) } catch (e: Exception) { false } + if (!ok) throw Malformed("$scheme signature over the signed data does not verify") + + val cert = try { + CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(s.certs[0])) as X509Certificate + } catch (e: Exception) { + throw Malformed("$scheme signer certificate is not a valid X.509 certificate") + } + if (!cert.publicKey.encoded.contentEquals(s.publicKey)) { + throw Malformed("$scheme signer public key is not its certificate's") + } + val alg = contentDigestOf(best.first)!! + val d = s.digests.getValue(best.first) + val prev = wanted[alg] + if (prev != null && !prev.contentEquals(d)) throw Malformed("$scheme signers disagree on the content digest") + wanted[alg] = d + certDigests.add(hex(sha256(s.certs[0]))) + } + for ((alg, expected) in wanted) { + val actual = contentDigest(f, layout, alg) + if (!actual.contentEquals(expected)) { + throw Malformed("the APK's contents do not match its $scheme signature (modified after signing)") + } + } + return Result.Verified(scheme, certDigests.distinct()) + } + + // ------------------------------------------------------------------ content digest + + /** The v2/v3 whole-file digest: 1 MiB chunks over [0, block), [cd, eocd), eocd with cd offset -> block. */ + private fun contentDigest(f: RandomAccessFile, layout: Layout, alg: Alg): ByteArray { + val chunkDigests = java.io.ByteArrayOutputStream() + var chunks = 0 + val md = MessageDigest.getInstance(alg.digest) + val buf = ByteArray(CHUNK) + val prefix = ByteArray(5) + fun chunk(data: ByteArray, n: Int) { + prefix[0] = 0xa5.toByte() + ByteBuffer.wrap(prefix, 1, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(n) + md.update(prefix); md.update(data, 0, n) + chunkDigests.write(md.digest()); chunks++ + } + fun section(start: Long, end: Long) { + var pos = start + f.seek(start) + while (pos < end) { + val n = minOf(CHUNK.toLong(), end - pos).toInt() + f.readFully(buf, 0, n) + chunk(buf, n); pos += n + } + } + section(0, layout.sigBlockStart) + section(layout.cdOffset, layout.eocdOffset) + val eocd = layout.eocd.copyOf() + ByteBuffer.wrap(eocd).order(ByteOrder.LITTLE_ENDIAN).putInt(16, layout.sigBlockStart.toInt()) + var off = 0 + while (off < eocd.size) { + val n = minOf(CHUNK, eocd.size - off) + chunk(eocd.copyOfRange(off, off + n), n); off += n + } + val top = ByteArray(5) + top[0] = 0x5a + ByteBuffer.wrap(top, 1, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(chunks) + md.update(top); md.update(chunkDigests.toByteArray()) + return md.digest() + } + + // ------------------------------------------------------------------ helpers + + private fun slice(b: ByteBuffer, pos: Int, len: Int): ByteBuffer { + val d = b.duplicate(); d.position(pos); d.limit(pos + len) + return d.slice().order(ByteOrder.LITTLE_ENDIAN) + } + + /** A u32-length-prefixed field, consumed from [b]. */ + private fun lp(b: ByteBuffer): ByteBuffer { + if (b.remaining() < 4) throw Malformed("truncated length prefix") + val n = b.int + if (n < 0 || n > b.remaining()) throw Malformed("length prefix $n exceeds the remaining ${b.remaining()} bytes") + val s = slice(b, b.position(), n) + b.position(b.position() + n) + return s + } + + private fun u32(b: ByteBuffer): Long { + if (b.remaining() < 4) throw Malformed("truncated field") + return b.int.toLong() and 0xffffffffL + } + + private fun bytes(b: ByteBuffer): ByteArray = ByteArray(b.remaining()).also { b.get(it) } + + private fun sha256(b: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(b) + + private fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) } +} diff --git a/android-installer/app/src/main/java/com/displayxr/installer/DisplayServices.kt b/android-installer/app/src/main/java/com/displayxr/installer/DisplayServices.kt index 6109085..0599d71 100644 --- a/android-installer/app/src/main/java/com/displayxr/installer/DisplayServices.kt +++ b/android-installer/app/src/main/java/com/displayxr/installer/DisplayServices.kt @@ -226,11 +226,14 @@ object DisplayServices { } /** - * What Android reads out of the downloaded archive must agree with the manifest AND - * with the certificate pin. Pure; the caller feeds it `getPackageArchiveInfo`. + * What is read out of the downloaded archive must agree with the manifest AND with the + * certificate pin. Pure; the caller feeds it [ApkInstaller.archiveIdentity]. * - * @param signers SHA-256 digests of the archive's CURRENT signing certificate(s), - * or empty when Android could not read them. Exactly one, equal to the pin. + * @param signers SHA-256 digests of the archive's CURRENT signing certificate(s), or + * empty when they could not be read. Exactly one, equal to the pin (and so to the + * manifest, whose `signer_sha256` [parseManifest] already held to the pin). + * @param signerProblem why [signers] is empty, when something more specific than + * "could not be read" is known (see [combineSigners]). * @return null when the archive is acceptable, otherwise the reason it is not. */ fun archiveProblem( @@ -238,6 +241,7 @@ object DisplayServices { archivePackage: String?, archiveVersionCode: Long?, signers: List, + signerProblem: String? = null, ): String? { val pinned = PINNED_SIGNERS[apk.packageName] ?: return "${apk.packageName} is not a display service this installer may install." @@ -249,17 +253,59 @@ object DisplayServices { return "${apk.fileName} is versionCode $archiveVersionCode, not ${apk.versionCode} as the manifest says." } if (signers.isEmpty()) { + if (signerProblem != null) { + return "The signing certificate of ${apk.fileName} could not be verified: $signerProblem. " + + "It was deleted and nothing was installed." + } return "Android could not read the signing certificate of ${apk.fileName}, so it cannot be " + "checked against the vendor key. Refused." } val norm = signers.map { it.lowercase() }.distinct() - if (norm != listOf(pinned)) { + if (norm != listOf(pinned) || apk.signerSha256.lowercase() != pinned) { return "${apk.fileName} is signed by ${norm.joinToString { it.take(16) + "…" }}, not the " + "vendor key this installer trusts (${pinned.take(16)}…). Refused — nothing was installed." } return null } + /** + * The archive's signer set, from two independent readers. + * + * [reader] is [ApkSignatureReader] — this app's own v2/v3 verifier, which does not + * depend on the tablet's framework build. [platform] is what + * `getPackageArchiveInfo(GET_SIGNING_CERTIFICATES | GET_SIGNATURES)` returned (empty + * when Android gave nothing — which the initial Android 13 framework does for + * GET_SIGNING_CERTIFICATES alone; see [ApkSignatureReader]). + * + * - reader verified: its answer, unless Android named a DIFFERENT set — then nothing, + * because two readers of one file disagreeing is not a state to install from; + * - reader found a signing block that does not verify: nothing, whatever Android says — + * a tampered or forged block is refused, never outvoted; + * - no v2/v3 block (a v1-only APK) or only algorithms the reader does not implement: + * Android's answer, which may be empty (then refused). + * + * Pure. + */ + fun combineSigners(reader: ApkSignatureReader.Result, platform: List): SignerReading { + val plat = platform.map { it.lowercase() }.distinct() + return when (reader) { + is ApkSignatureReader.Result.Verified -> { + if (plat.isNotEmpty() && plat.toSet() != reader.certSha256.toSet()) { + SignerReading( + emptyList(), + "Android reads signer ${plat.joinToString { it.take(16) + "…" }} but the APK's " + + "${reader.scheme} signature names ${reader.certSha256.joinToString { it.take(16) + "…" }}", + ) + } else { + SignerReading(reader.certSha256, null) + } + } + is ApkSignatureReader.Result.Invalid -> SignerReading(emptyList(), reader.why) + is ApkSignatureReader.Result.NoSigningBlock, + is ApkSignatureReader.Result.Unsupported -> SignerReading(plat, null) + } + } + // ------------------------------------------------------------ staleness /** @@ -313,6 +359,9 @@ data class ServiceApk( val signerSha256: String, ) +/** Output of [DisplayServices.combineSigners]: the signer digests, or why there are none. */ +data class SignerReading(val signers: List, val problem: String?) + data class LicenseFile(val name: String, val url: String) data class ServiceManifest( diff --git a/android-installer/app/src/main/java/com/displayxr/installer/InstallerViewModel.kt b/android-installer/app/src/main/java/com/displayxr/installer/InstallerViewModel.kt index 8f5cec0..472bb1e 100644 --- a/android-installer/app/src/main/java/com/displayxr/installer/InstallerViewModel.kt +++ b/android-installer/app/src/main/java/com/displayxr/installer/InstallerViewModel.kt @@ -456,8 +456,8 @@ class InstallerViewModel(app: Application) : AndroidViewModel(app) { ) } DisplayServices.verifyFile(apk, svc) - val (archPkg, archCode, signers) = ApkInstaller.archiveIdentity(ctx, apk) - DisplayServices.archiveProblem(svc, archPkg, archCode, signers)?.let { why -> + val id = ApkInstaller.archiveIdentity(ctx, apk) + DisplayServices.archiveProblem(svc, id.packageName, id.versionCode, id.signers, id.signerProblem)?.let { why -> apk.delete() throw ServiceVerificationException(why) } diff --git a/android-installer/app/src/test/java/com/displayxr/installer/ApkSignatureReaderTest.kt b/android-installer/app/src/test/java/com/displayxr/installer/ApkSignatureReaderTest.kt new file mode 100644 index 0000000..d1c5e0f --- /dev/null +++ b/android-installer/app/src/test/java/com/displayxr/installer/ApkSignatureReaderTest.kt @@ -0,0 +1,316 @@ +package com.displayxr.installer + +import com.android.apksig.ApkSigner +import com.android.apksig.SigningCertificateLineage +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.Date +import java.util.Random +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * [ApkSignatureReader] against APKs signed AT TEST TIME with apksig (the library apksigner + * is built on) and throwaway self-signed keys — no vendor bytes and no key in the repo. + * + * The case that matters is the first one: a v2-ONLY APK, which is exactly how the vendor + * display services are signed and exactly what the initial Android 13 framework failed to + * read through `getPackageArchiveInfo(GET_SIGNING_CERTIFICATES)` (0.4.0 on the NP02J/K68). + * + * The real vendor APKs are exercised too, but only when `DXR_SERVICE_APK_DIR` points at a + * local copy (device-service.apk + headtracking-service.apk) — they are never committed. + */ +class ApkSignatureReaderTest { + + @get:Rule val tmp = TemporaryFolder() + + private class Key(val name: String, val key: PrivateKey, val cert: X509Certificate) { + val sha256: String get() = hex(MessageDigest.getInstance("SHA-256").digest(cert.encoded)) + } + + companion object { + private lateinit var rsaA: Key + private lateinit var rsaB: Key + private lateinit var ecC: Key + + @BeforeClass @JvmStatic + fun keys() { + rsaA = key("A", "RSA", 2048, "SHA256withRSA") + rsaB = key("B", "RSA", 2048, "SHA256withRSA") + ecC = key("C", "EC", 256, "SHA256withECDSA") + } + + private fun key(name: String, alg: String, bits: Int, sigAlg: String): Key { + val kp = KeyPairGenerator.getInstance(alg).apply { initialize(bits) }.generateKeyPair() + val dn = X500Name("CN=DisplayXR test signer $name") + val now = System.currentTimeMillis() + val holder = JcaX509v3CertificateBuilder( + dn, BigInteger.valueOf(now), Date(now - 86_400_000L), Date(now + 3_650L * 86_400_000L), dn, kp.public, + ).build(JcaContentSignerBuilder(sigAlg).build(kp.private)) + return Key(name, kp.private, JcaX509CertificateConverter().getCertificate(holder)) + } + + fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) } + } + + /** A plausible unsigned APK: > 2 MiB so the content digest spans several 1 MiB chunks. */ + private fun unsigned(): File { + val f = tmp.newFile() + ZipOutputStream(f.outputStream()).use { z -> + z.putNextEntry(ZipEntry("AndroidManifest.xml")); z.write("not a real manifest".toByteArray()); z.closeEntry() + val stored = ByteArray(2_500_000).also { Random(7).nextBytes(it) } + val e = ZipEntry("classes.dex").apply { + method = ZipEntry.STORED; size = stored.size.toLong(); compressedSize = stored.size.toLong() + crc = java.util.zip.CRC32().also { it.update(stored) }.value + } + z.putNextEntry(e); z.write(stored); z.closeEntry() + z.putNextEntry(ZipEntry("res/raw/a.txt")); z.write(ByteArray(50_000) { (it % 7).toByte() }); z.closeEntry() + } + return f + } + + private fun sign( + vararg signers: Key, + v1: Boolean = false, v2: Boolean = true, v3: Boolean = false, + lineage: SigningCertificateLineage? = null, rotationMinSdk: Int? = null, + ): File { + val out = tmp.newFile() + ApkSigner.Builder(signers.map { ApkSigner.SignerConfig.Builder(it.name, it.key, listOf(it.cert)).build() }) + .setInputApk(unsigned()).setOutputApk(out) + .setMinSdkVersion(24) + .setV1SigningEnabled(v1).setV2SigningEnabled(v2).setV3SigningEnabled(v3) + .apply { if (lineage != null) setSigningCertificateLineage(lineage) } + .apply { if (rotationMinSdk != null) setMinSdkVersionForRotation(rotationMinSdk) } + .build().sign() + return out + } + + private fun verified(r: ApkSignatureReader.Result): ApkSignatureReader.Result.Verified { + assertTrue("expected Verified, got $r", r is ApkSignatureReader.Result.Verified) + return r as ApkSignatureReader.Result.Verified + } + + private fun invalid(r: ApkSignatureReader.Result, containing: String) { + assertTrue("expected Invalid, got $r", r is ApkSignatureReader.Result.Invalid) + assertTrue("message was: ${(r as ApkSignatureReader.Result.Invalid).why}", r.why.contains(containing)) + } + + private fun flipByteAt(f: File, offset: Long) { + java.io.RandomAccessFile(f, "rw").use { raf -> + raf.seek(offset); val b = raf.read(); raf.seek(offset); raf.write(b xor 0x01) + } + } + + private fun indexOf(hay: ByteArray, needle: ByteArray): Int { + outer@ for (i in 0..hay.size - needle.size) { + for (j in needle.indices) if (hay[i + j] != needle[j]) continue@outer + return i + } + return -1 + } + + // ---- what it reads ----------------------------------------------------------------- + + @Test + fun `a v2-only APK yields exactly its signer's certificate digest`() { + val r = verified(ApkSignatureReader.read(sign(rsaA, v2 = true), 33)) + assertEquals("v2", r.scheme) + assertEquals(listOf(rsaA.sha256), r.certSha256) + // Every API the installer supports reads it the same way (v2 has no SDK range). + for (sdk in 29..35) assertEquals(listOf(rsaA.sha256), verified(ApkSignatureReader.read(sign(rsaA), sdk)).certSha256) + } + + @Test + fun `an ECDSA v2 signer verifies too`() { + assertEquals(listOf(ecC.sha256), verified(ApkSignatureReader.read(sign(ecC), 33)).certSha256) + } + + @Test + fun `v1 plus v2 reads the v2 block`() { + val r = verified(ApkSignatureReader.read(sign(rsaA, v1 = true, v2 = true), 31)) + assertEquals("v2", r.scheme) + assertEquals(listOf(rsaA.sha256), r.certSha256) + } + + @Test + fun `v2 plus v3 reads the v3 block`() { + val r = verified(ApkSignatureReader.read(sign(rsaA, v2 = true, v3 = true), 33)) + assertEquals("v3", r.scheme) + assertEquals(listOf(rsaA.sha256), r.certSha256) + } + + @Test + fun `after a key rotation it is the CURRENT key, as Android compares it`() { + val lineage = SigningCertificateLineage.Builder( + SigningCertificateLineage.SignerConfig.Builder(rsaA.key, rsaA.cert).build(), + SigningCertificateLineage.SignerConfig.Builder(rsaB.key, rsaB.cert).build(), + ).build() + // Rotation applied from API 28: v3 carries B (new), v2 still A (old). + val apk = sign(rsaA, rsaB, v2 = true, v3 = true, lineage = lineage, rotationMinSdk = 28) + val r = verified(ApkSignatureReader.read(apk, 31)) + assertEquals(listOf(rsaB.sha256), r.certSha256) + + // Rotation targeting API 33 (apksig's default): v3.1 carries B for 33+, v3 keeps A below. + val apk31 = sign(rsaA, rsaB, v2 = true, v3 = true, lineage = lineage, rotationMinSdk = 33) + assertEquals(listOf(rsaB.sha256), verified(ApkSignatureReader.read(apk31, 33)).certSha256) + assertEquals(listOf(rsaA.sha256), verified(ApkSignatureReader.read(apk31, 31)).certSha256) + } + + @Test + fun `two v2 signers yield both, and the pin rule refuses that set`() { + val r = verified(ApkSignatureReader.read(sign(rsaA, rsaB, v2 = true), 33)) + assertEquals(setOf(rsaA.sha256, rsaB.sha256), r.certSha256.toSet()) + assertEquals(2, r.certSha256.size) + // Even when one of them is the pinned vendor key, a set of two is not the pinned signer. + val svc = realManifest().services.first { it.packageName == DisplayServices.DEVICE_SERVICE } + val pinned = DisplayServices.PINNED_SIGNERS.getValue(DisplayServices.DEVICE_SERVICE) + assertNotNull(DisplayServices.archiveProblem(svc, svc.packageName, svc.versionCode, listOf(pinned, rsaA.sha256))) + } + + // ---- what it refuses --------------------------------------------------------------- + + @Test + fun `a v1-only APK has no signing block - Android must answer, not this reader`() { + val r = ApkSignatureReader.read(sign(rsaA, v1 = true, v2 = false), 33) + assertTrue("got $r", r is ApkSignatureReader.Result.NoSigningBlock) + } + + @Test + fun `an unsigned APK has no signing block`() { + assertTrue(ApkSignatureReader.read(unsigned(), 33) is ApkSignatureReader.Result.NoSigningBlock) + } + + @Test + fun `a file that is not a ZIP has no signing block`() { + val f = tmp.newFile().apply { writeBytes(ByteArray(4096) { 0x41 }) } + assertTrue(ApkSignatureReader.read(f, 33) is ApkSignatureReader.Result.NoSigningBlock) + } + + @Test + fun `one byte changed in the contents after signing is refused`() { + val apk = sign(rsaA) + flipByteAt(apk, 1_500_000) // inside classes.dex, in the second 1 MiB chunk + invalid(ApkSignatureReader.read(apk, 33), "modified after signing") + } + + @Test + fun `one byte changed in the central directory is refused`() { + val apk = sign(rsaA) + val bytes = apk.readBytes() + val cd = indexOf(bytes, byteArrayOf(0x50, 0x4b, 0x01, 0x02)) // first central-directory header + assertTrue(cd > 0) + flipByteAt(apk, cd + 30L + 4) // inside a file name + invalid(ApkSignatureReader.read(apk, 33), "modified after signing") + } + + @Test + fun `a certificate altered inside the signing block is refused`() { + // The forgery that matters: someone else's certificate in the block, without their key. + val apk = sign(rsaA) + val bytes = apk.readBytes() + val at = indexOf(bytes, rsaA.cert.encoded) + assertTrue(at > 0) + flipByteAt(apk, at + rsaA.cert.encoded.size - 3L) // in the cert's own signature: still parses + invalid(ApkSignatureReader.read(apk, 33), "does not verify") + } + + @Test + fun `a truncated APK is refused or unreadable, never Verified`() { + val apk = sign(rsaA) + val cut = tmp.newFile().apply { writeBytes(apk.readBytes().copyOf(apk.length().toInt() - 10)) } + val r = ApkSignatureReader.read(cut, 33) + assertTrue("got $r", r !is ApkSignatureReader.Result.Verified) + } + + // ---- combining with what Android says ---------------------------------------------- + + @Test + fun `the Android 13 case - platform empty, reader verified - uses the reader`() { + val v = ApkSignatureReader.Result.Verified("v2", listOf(rsaA.sha256)) + assertEquals(SignerReading(listOf(rsaA.sha256), null), DisplayServices.combineSigners(v, emptyList())) + assertEquals(SignerReading(listOf(rsaA.sha256), null), DisplayServices.combineSigners(v, listOf(rsaA.sha256.uppercase()))) + } + + @Test + fun `platform and reader disagreeing is refused`() { + val v = ApkSignatureReader.Result.Verified("v2", listOf(rsaA.sha256)) + val r = DisplayServices.combineSigners(v, listOf(rsaB.sha256)) + assertTrue(r.signers.isEmpty()) + assertNotNull(r.problem) + } + + @Test + fun `an invalid block is refused whatever Android says`() { + val r = DisplayServices.combineSigners(ApkSignatureReader.Result.Invalid("tampered"), listOf(rsaA.sha256)) + assertTrue(r.signers.isEmpty()) + assertEquals("tampered", r.problem) + val svc = realManifest().services.first() + val why = DisplayServices.archiveProblem(svc, svc.packageName, svc.versionCode, r.signers, r.problem)!! + assertTrue(why, why.contains("tampered") && why.contains("could not be verified")) + } + + @Test + fun `no signing block falls back to Android, and nothing from either is still refused`() { + val none = ApkSignatureReader.Result.NoSigningBlock("v1 only") + assertEquals(listOf(rsaA.sha256), DisplayServices.combineSigners(none, listOf(rsaA.sha256)).signers) + val empty = DisplayServices.combineSigners(none, emptyList()) + assertTrue(empty.signers.isEmpty()) + val svc = realManifest().services.first() + assertNotNull(DisplayServices.archiveProblem(svc, svc.packageName, svc.versionCode, empty.signers, empty.problem)) + } + + @Test + fun `a synthetic signer that verifies is still not the vendor key`() { + val svc = realManifest().services.first() + val r = DisplayServices.combineSigners(ApkSignatureReader.read(sign(rsaA), 33), emptyList()) + assertEquals(listOf(rsaA.sha256), r.signers) + assertTrue(DisplayServices.archiveProblem(svc, svc.packageName, svc.versionCode, r.signers, r.problem)!!.contains("Refused")) + } + + // ---- the real vendor APKs, local only ---------------------------------------------- + + private fun realManifest(): ServiceManifest { + val json = javaClass.classLoader!!.getResource("services-manifest-v0.10.69.json")!!.readText() + return DisplayServices.parseManifest(json, DisplayServices.manifestUrl("v0.10.69"), "v0.10.69") + } + + @Test + fun `the real vendor services read as their pinned certificates (local, DXR_SERVICE_APK_DIR)`() { + val dir = System.getenv("DXR_SERVICE_APK_DIR")?.let(::File) + assumeTrue("DXR_SERVICE_APK_DIR not set — vendor APKs are never committed", dir != null && dir.isDirectory) + val files = mapOf( + DisplayServices.DEVICE_SERVICE to File(dir, "device-service.apk"), + DisplayServices.HEADTRACKING to File(dir, "headtracking-service.apk"), + ) + val m = realManifest() + for ((pkg, f) in files) { + for (sdk in listOf(29, 31, 33, 35)) { + val r = verified(ApkSignatureReader.read(f, sdk)) + assertEquals("v2", r.scheme) + assertEquals(listOf(DisplayServices.PINNED_SIGNERS.getValue(pkg)), r.certSha256) + println("REAL $pkg api$sdk ${r.scheme} ${r.certSha256}") + } + // End to end through the same decision the app makes, with Android 13's empty answer. + val svc = m.services.first { it.packageName == pkg } + val reading = DisplayServices.combineSigners(ApkSignatureReader.read(f, 33), emptyList()) + assertNull(DisplayServices.archiveProblem(svc, pkg, svc.versionCode, reading.signers, reading.problem)) + } + } +} diff --git a/android-installer/gradle.properties b/android-installer/gradle.properties index a133dae..0739131 100644 --- a/android-installer/gradle.properties +++ b/android-installer/gradle.properties @@ -18,8 +18,12 @@ # sha256 and the vendor signing certificate, and installs them first; if it cannot, it says # 3D will not work instead of finishing as a success. The with-services (cnsdk) flavor is # retired. -installerVersionName=0.4.0 -installerVersionCode=5 +# 0.4.1: the display-service signing certificate is read and VERIFIED by the app itself from +# the APK Signing Block (v2/v3/v3.1). 0.4.0 relied on getPackageArchiveInfo(GET_SIGNING_ +# CERTIFICATES), which the initial Android 13 framework (NP02J/K68) answers with a null +# signingInfo — so it refused the tablet's own v2-signed vendor update. +installerVersionName=0.4.1 +installerVersionCode=6 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.parallel=true