Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions android-installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
6 changes: 6 additions & 0 deletions android-installer/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/** Why [signers] is empty, when known. */
val signerProblem: String?,
)

sealed class InstallOutcome {
object Success : InstallOutcome()

Expand Down Expand Up @@ -239,32 +249,55 @@ 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<String?, Long?, List<String>> {
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()
si.hasMultipleSigners() -> si.apkContentsSigners ?: emptyArray()
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)
}

/**
Expand Down
Loading
Loading