Skip to content
academia.sh

Lesson 11 / 14

App Signing

Every released version is signed with a signing key, and the device installs an update only if it comes from the same certificate as the installed app. The lesson measures how many copies of a released version reached the installed app on the device, and what happens to a version signed with a different key.

Published Updated

In this lesson

  • Separates the roles of the signing key, the certificate, and the upload key
  • Explains that the certificate must match for an update to be installed
  • Measures that an update signed with the same certificate arrives once
  • Distinguishes an update signed with a different key as lost, or superseded when a later version exists
Contents

In the previous lesson, the event was a refresh going from the device to the server. In this lesson the direction reverses: a developer releases a version, and that version wants to reach the app installed on the device. The event is the released version, and the receiver is the installed app on the device. The gate in between is a signature. The question is the course’s question: how many copies of this sent event reached the receiver?

// Arrival.kt — classifies how many copies of a sent event reached the receiver
class Signal(val key: String, val seq: Int)

class Arrival(val label: String, val sent: Signal, received: List<Signal>) {
    val copies: Int = received.count { it.seq == sent.seq }
    val verdict: String = when {
        copies > 1 -> "duplicated"
        copies == 1 -> "once"
        received.any { it.key == sent.key && it.seq > sent.seq } -> "superseded"
        else -> "lost"
    }

    fun line() {
        println("$label: key=${sent.key} | copies=$copies -> $verdict")
    }
}

fun countArrivals(arrivals: List<Arrival>) {
    for (name in listOf("once", "duplicated", "superseded", "lost")) {
        println("$name: ${arrivals.count { it.verdict == name }}")
    }
}

In this lesson the key is the identity of the app, and seq is the version order. Versions of the same app carry the same key and increasing seq values.

The Signing Key and the Certificate

An app package is signed with a signing key. The document that carries the public half of the key is the certificate, and it comes down to the device with the package. Before installing a new version, the device compares its certificate with the certificate of the installed app. If they match, the update is installed; if they do not, it is rejected and the installed version stays in place.

This comparison answers an identity question: is the sender of the new version the same party as the sender of the installed app? If the key changes, the device does not treat the new version as the same app as the old one, except under special arrangements such as key rotation. That is why the signing key is the one thing that must stay the same from version to version.

In some app store arrangements, the developer signs the package with an upload key and uploads it to the app store; the app store re-signs the package with the signing key it holds and distributes it to devices. In that case, the certificate the device sees is not the upload key’s but the certificate of the signing key at the app store. From inside the app, you can look at the digest of its own certificate.

// Platform code: PackageManager.getPackageInfo
val info = packageManager.getPackageInfo(
    packageName,
    PackageManager.GET_SIGNING_CERTIFICATES,
)
val signers = info.signingInfo?.apkContentsSigners ?: emptyArray()
val digest = MessageDigest.getInstance("SHA-256").digest(signers[0].toByteArray())

This piece is platform code; we make no claim here about output or digest values. Which tool does the signing is not the subject of this lesson and is not written. The model builds the platform’s rule; the model is not the platform itself.

In the model, a release carries the event and the certificate that signed it. The device holds a single certificate that it trusts, and with the verifySignature function it compares the release’s certificate against it. The releases that pass go on to installation, and they are the receiver’s list.

// Install.kt — the device does not install a version whose certificate does not match the one it trusts
class Release(val signal: Signal, val cert: String)

class Device(private val trusted: String) {
    fun verifySignature(release: Release): Boolean = release.cert == trusted
}

fun installUpdates(device: Device, releases: List<Release>): List<Signal> =
    releases.filter { device.verifySignature(it) }.map { it.signal }
// Probe.kt — the receiver counts the installed list for the released versions
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }

The Signature as Identity

To understand why the comparison is so strict, we need to look at what the device is trying to protect. The device has a package name, and a name is a string anyone can write. If a second package with the same name could take the place of the installed app, the user’s data would be handed over to an app whose owner is unclear. The certificate closes this gap: the name claims an identity, and the certificate proves it.

This has two consequences. The first is that for an update to be installed, it is not enough for the package contents to be right; the signature must also come from the right key. The second is that even if the signature is right, if the developer loses or changes the key, later versions cannot reach the installed app. The device reads this not as a configuration error but as a package from a different party.

In the model, both consequences are a single line in the Device class: release.cert == trusted. The model does not know how a certificate is produced, with which algorithm it is signed, or arrangements such as key rotation. There is only a match or no match.

The Same Certificate

Three versions are released, and all three are signed with the certificate the device trusts.

// Main.kt — every update signed with the same certificate is installed
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-a"),
        Release(v3, "cert-a"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2, v3), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=1 -> once
event-3: key=app.main | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0

All three versions are installed, and one copy of each arrives. This lesson’s delivery: once. An update signed with the same certificate passes the device’s comparison and reaches the installed app as it is.

The Same Version by Two Routes

An update can reach a device by two routes: from the app store and from a file loaded by hand. If both carry the same certificate, both pass the comparison.

// Main.kt — if the same version comes by two routes, two copies arrive
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-a"),
        Release(v2, "cert-a"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=2 -> duplicated
once: 1
duplicated: 1
superseded: 0
lost: 0

The signature comparison does not look at whether a version arrives a second time; it looks only at the certificate. On a real device, a second installation of the same version mostly does not change the outcome, but the rule counts copies and writes two as duplicated.

SE1. The model builds installation as appending to a list. On the platform, a second installation of the same version may leave the installed app in place or rewrite it; the model does not make this distinction.

SE2. The model builds the certificate comparison as a string equality. On the platform, the comparison varies with the signing scheme, the key rotation history, and multiple signers.

The Bounding Measurement

Four versions are released. The first and third are signed with the trusted certificate; the second and fourth are signed with a different key.

// Main.kt — a version signed with a different key is rejected
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val v4 = Signal("app.main", 4)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-b"),
        Release(v3, "cert-a"),
        Release(v4, "cert-b"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2, v3, v4), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=0 -> superseded
event-3: key=app.main | copies=1 -> once
event-4: key=app.main | copies=0 -> lost
once: 2
duplicated: 0
superseded: 1
lost: 1

The second and fourth versions were rejected for the same reason: their certificates do not match the one the device trusts. By the rule, though, one is superseded and the other is lost. After the second version, a greater seq arrived under the same key, the third version; so the second is superseded. After the fourth, no such copy arrived; the installed version, 3, is smaller than the fourth. Because no later event exists, the installed version does not count as having superseded the fourth, so the fourth is lost.

For the user, the difference is this. A superseded version is an old version that is no longer needed; a lost version is a new version that never arrives, and the user cannot receive its fixes. The rule shows this difference with a category, but it does not say the reason, that the key is wrong.

The most serious form of losing the key is that after one version, all updates are signed with a different key. The first version carries the trusted certificate, and the next three carry a new key.

// Main.kt — when the key changes, every later version is rejected
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val v4 = Signal("app.main", 4)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-b"),
        Release(v3, "cert-b"),
        Release(v4, "cert-b"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2, v3, v4), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=0 -> lost
event-3: key=app.main | copies=0 -> lost
event-4: key=app.main | copies=0 -> lost
once: 1
duplicated: 0
superseded: 0
lost: 3

The first version arrived, and the three new versions did not. None is superseded, because the newest version installed on the device is the first, and no greater seq arrived to displace them. Once the key changes, the loss is not limited to a single version; every later version snags on the same gate. In the previous measurement, when a new version with the trusted certificate came after the rejected one, superseded arose; here there is no such version.

The same setup reverses once the device moves to the new key. The user uninstalls the old app and does a clean install of the version signed with the new key; the certificate the device trusts is now cert-b.

// Main.kt — after a clean install, the device trusts the new certificate
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-b"),
        Release(v3, "cert-b"),
    )
    val received = installUpdates(Device("cert-b"), releases)
    val arrivals = probe(listOf(v1, v2, v3), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
event-1: key=app.main | copies=0 -> superseded
event-2: key=app.main | copies=1 -> once
event-3: key=app.main | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 0

The first version, signed with the old key, is now rejected; but because newer versions that replace it arrived, it is superseded. The same three versions produced superseded instead of lost when the trusted certificate changed. This shows once more that the rule sees only the position of the copies, not the cause. The cost of the clean install to the user does not show in the measurement: the old app’s data goes away with the uninstall, and the measurement does not count it.

SE3. The model treats a rejected version as an event separate from the installed version. On the platform, a rejected update ends with an error message, and the installed app does not change.

The Silence of Rejection

Looking at the installUpdates function once more, the model’s most important property shows: nothing is produced for a rejected version. No error record is added to the list, no marker is left; the receiver is left with only what was installed. That is why probe sees a rejected version as “copies zero”. The difference between lost and once is only whether there is a row in the list.

This design is deliberate, because the lesson’s question is how many copies reached the receiver, not why they did not. But it has a consequence for the reader: a version being lost does not say where the loss happened. A certificate mismatch, a package corrupted on the way, and an installation never attempted all give the same zero. Three different causes collapse into a single number; to look for the cause, you have to look outside the measurement, at the publisher’s own records.

The classification rule, though, splits zero into two branches. If a greater seq under the same key arrived, the zero is superseded; if not, lost. A certificate rejection does not make this distinction itself; later versions make it. The same rejected version is written as superseded when a version with the trusted certificate comes after it, and as lost when none does. So for a publisher the safest reading is this: if the lost count is greater than zero, nothing has reached the device after those versions, and this means the user is stuck on the old version.

Versions do not have to arrive in order. The model processes versions in the order they were sent, but a real device can skip a version and move on to the next. In that case the copy count for the skipped version is zero, and because the next version arrived, the classification is superseded, not lost. So, independent of certificate rejection, the difference between “the version never came down to the device” and “the version came down but the next one displaced it” depends only on the version that comes later.

The version number and the certificate are separate identities. The version number determines the direction of installation, and the certificate determines whether installation is allowed. Neither replaces the other; a version with a larger number that comes with the wrong certificate is rejected, and a version with a smaller number that comes with the right certificate is evaluated in the measurement only as an order.

What the Publisher Has to Do

The receiver of this lesson is the device, but it is the publisher who will prevent the loss. The checklist is short.

  • Keep the signing key independent of the version. The key is not a part of a version; it is the identity of the app. If it is lost, the three-lost measurement above is what you live through. A backup of the key should be kept somewhere separate from the environment that uses it.
  • Compare the certificate digest before release. If the new package’s certificate digest does not match that of the previous version, the release is stopped. This comparison is the same comparison the device will make, only done before the user.
  • Do dual-route distribution deliberately. If the same version is distributed both through the app store and by hand, the certificates of the two must be the same. Otherwise one is installed and the other is rejected, and which is which is not visible to the user.
  • Make rejection observable. A rejected update reports nothing to the server. The publisher’s way of noticing is to look at the installed version distribution: if there is an audience that never sees the new version, the loss is in that audience.

Common Misconceptions

If the package name is the same, the update is installed. The name is not enough; the certificate must match too. In the four-version measurement, all versions had the same name and two were rejected anyway.

A rejected update is always lost. It is not. If a newer version with the trusted certificate arrived afterwards, it is superseded. The difference between lost and superseded lies not in the cause of the rejection but in what came afterwards.

A key change affects only one version. It does not. When the key changed, every later version was rejected; all three versions became lost.

The same version arriving twice is an error. From the device’s point of view it may not be an error; but the rule counts that two copies arrived and writes duplicated. Whether it is an error depends on the outcome of the reinstall, not on the count.

Summary

  • This lesson’s delivery: once. An update signed with the certificate the device trusts passes the comparison and reaches the installed app as one copy.
  • The signing key is the one thing that must stay the same from version to version; the certificate comes down to the device with the package.
  • All three versions arrive once with the same certificate: once 3.
  • If the same version is installed by two routes, two copies are counted: once 1, duplicated 1.
  • A version signed with a different key is rejected: superseded if a later version exists, lost if not. In the four versions: once 2, superseded 1, lost 1. If the key changes after one version, all three versions are lost: once 1, lost 3.

Next Step

The version is now on the device and running. To know what the app does while running, you need to collect events. In the next lesson, the app sends analytics events and crash reports; when the response to a send is lost, the same event is retried without deduplication.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close