Mobile Computing · Mobile Release
The APK Signing Block and Whole-File Digest Verification
How the v2 signature sits between the ZIP entries and the central directory, how a digest covers a file that contains the digest, and why JAR signing could not do this.
An APK is a ZIP archive, and the ZIP format was designed for extraction, not for authentication. Its central directory is a lookup table that a reader is free to interpret in more than one way, entries can be added without touching existing ones, and nothing forces every byte of the file to belong to some entry. A signature scheme built on top of that structure inherits the ambiguity. The APK Signature Scheme v2 was introduced to remove it by signing the archive as a single byte string instead of signing its pieces.
What JAR signing verified, and what it left out
The original scheme, now called v1, is ordinary JAR signing. A manifest file lists each entry with a digest, a signature file repeats those digests in a block that is itself signed, and the whole thing lives in META-INF/. Verification walks the central directory, computes the digest of each entry’s uncompressed content, and compares.
The gap is what the scheme does not enumerate. Bytes that belong to no entry are simply not covered. In 2017 this became concrete: a file could be constructed that begins with a valid DEX header and continues into a valid signed APK, and the JAR verification would pass because every ZIP entry still matched while the leading DEX, outside the archive’s entry list, was what the runtime loaded. The platform accepted the file as an application. Fixing this in the verifier is possible, but the lesson is structural: any scheme that authenticates a list of regions cannot authenticate the file.
v2 inverts the relationship. The unit of verification is the file, and the ZIP structure becomes a hint about how to read it rather than the definition of its contents.
The block’s position in the archive
A ZIP file ends with an end-of-central-directory record (EOCD), 22 bytes plus an optional comment of up to 65,535 bytes, containing the offset of the central directory. A signed APK inserts one extra region between the entries and the central directory:
[ local entries ][ APK Signing Block ][ central directory ][ EOCD ]
The block is a sequence of length-prefixed pairs wrapped in a self-describing envelope:
| Field | Size | Contents |
|---|---|---|
| size of block | 8 bytes, little-endian | everything after this field, including the trailing copy of it |
| id-value pair | 8 + 4 + n bytes | pair length, 32-bit ID, value |
| size of block | 8 bytes | repeated, so the block can be read backwards |
| magic | 16 bytes | APK Sig Block 42 |
The repeated size and the magic at the end exist because verification starts at the central directory and walks backwards: the last 16 bytes before the central directory identify the region, and the 8 bytes before them give its length. The ID is a 32-bit constant identifying the scheme — the v2 block and the v3 block are separate pairs in the same envelope, so an APK can carry both without duplication of structure. Ordering within the envelope is not significant to the format, but every pair is length-delimited, which is what makes the container extensible without breaking old verifiers.
Walking the block requires no decompression and no knowledge of the entries, which is why the platform can locate a signature before it has parsed a single resource:
import struct
MAGIC = b"APK Sig Block 42"
def signing_block(data: bytes):
eocd = data.rfind(b"PK\x05\x06", max(0, len(data) - 22 - 0xFFFF))
if eocd < 0:
raise ValueError("no end of central directory record")
cd_offset = struct.unpack_from("<I", data, eocd + 16)[0]
if data[cd_offset - 16:cd_offset] != MAGIC:
return {}
size = struct.unpack_from("<Q", data, cd_offset - 24)[0]
pos, end, pairs = cd_offset - size - 8 + 8, cd_offset - 24, {}
while pos < end:
length = struct.unpack_from("<Q", data, pos)[0]
pair_id = struct.unpack_from("<I", data, pos + 8)[0]
pairs[pair_id] = data[pos + 12:pos + 8 + length]
pos += 8 + length
return pairs
The loop has no error recovery on purpose: a malformed pair boundary means the block is not a block, and a verifier that guesses at a length field is a verifier that can be steered.
Computing a digest over a file that contains its own digest
The awkward part of whole-file signing is circularity. The digest must cover the file, but the file contains the digest, and the block’s length depends on the size of the signature, which depends on the bytes being signed.
The scheme breaks the circle by excluding the block and repairing the one field that would otherwise leak the block’s existence. The digest input is:
- the contents of all ZIP entries, in file order, up to the start of the signing block;
- the central directory;
- the EOCD, with its 4-byte central directory offset rewritten to point at the start of the signing block rather than past it.
Step 3 is the interesting one. Without the rewrite, an attacker who removes the block would shift the central directory and change the EOCD field, and the remaining bytes would form a perfectly ordinary unsigned archive. By pinning the offset to the block’s start position, the signed bytes describe a file that is claimed to have a block at that offset. Removing the block changes a signed field.
The digest itself is not a single hash of the concatenation, because hashing 100 MB in one pass gives a verifier no way to overlap I/O with verification and no way to report where damage occurred. Instead the input is split into 1 MiB chunks, each chunk is hashed with SHA-256 to 32 bytes, and the digests are concatenated and hashed again. The final chunk is special: its digest is computed over a prefix byte 0xa5 followed by the 8-byte little-endian length of that chunk and then its contents. The prefix and length stop a truncated file from producing a digest that matches a longer one, since without them the last chunk could be shortened and its digest would still be a valid leaf.
The result is a Merkle tree of depth two over the file, with a fixed 1 MiB leaf size. Verification can hash chunks in parallel and compare only the top-level digest, and a corrupted byte localises to one leaf.
What the signer actually signs
Inside the v2 pair the structure is a list of signers, each carrying signed data, a set of signatures over that data, and a public key. The signed data contains the digest described above, the signing certificate chain, and a set of additional attributes as raw ID-value pairs, which is where future schemes hang metadata without changing the outer structure.
Verification is then four independent checks: the whole-file digest recomputed from the archive matches the signed digest; each signature verifies against the public key over the signed-data bytes; the public key matches the first certificate in the chain; and the certificate chain is acceptable to the platform. Only the first check touches the archive’s contents, which is why the digest and the signature can be validated in either order.
The separation also explains why the same signed data can be reused across schemes. The v3 block carries the same digest, the same certificate, and a proof-of-rotation record as an additional attribute, so a device that understands v3 does not re-read the file to learn something v2 already established. That structure is what makes signing key rotation a matter of adding a lineage to the signer rather than inventing a second file format.
Stripping, downgrades, and the one attribute that closes the hole
A v2 signature on an APK that also carries a v1 signature creates a downgrade path. Delete the signing block, and the file is still a valid v1-signed archive; a device that prefers v2 would find no v2 block and fall back. The platform’s answer is a manifest attribute, X-Android-APK-Signed, whose value lists the scheme IDs the APK was signed with. A v1 verifier that understands the attribute refuses an APK that claims v2 in its manifest but has no v2 block. Older platforms ignore the attribute, which is the point: the protection only needs to exist where the newer scheme exists.
The v1 signature is still present in most published APKs, because devices below Android 7 do not implement v2 and would otherwise refuse to install. That means the file carries two authentication structures with different notions of what the file is, and a verifier must decide which one governs. The rule is ordered by platform version, not by preference: a device that can check v2 checks v2 and treats v1 as advisory.
Where the design pays
Whole-file verification costs a full read of the archive at install time, and another at every update check that validates a downloaded file. For an APK of tens of megabytes this is a sequential read of the whole file with SHA-256, which on phone storage is bounded by the storage device rather than the CPU. The alternative — per-entry verification — reads the same bytes but can skip entries the install does not need, which matters for a 200 MB game with optional asset packs.
There is a second cost, subtler: a whole-file digest cannot be verified while the file is still arriving in arbitrary order. Streaming installs need per-page hashes, which is why v4 exists as a separate side file containing a Merkle tree over 4 KiB pages, and why atomic install sessions can start an app before the download finishes. v2’s chunking is a compromise: fine enough to parallelise, too coarse to stream a partially downloaded file.
The container itself has limits. The EOCD’s central directory offset is a 32-bit field, so an APK’s structures must fit under 4 GiB, and a signing block placed near that boundary leaves little room. A block that is well-formed but empty, or one whose magic appears inside an EOCD comment, are the two shapes a naive backward scan misreads; a correct verifier validates that the size field is consistent with the offset it was found at instead of trusting the first magic it encounters.
References
- APK Signature Scheme v2 — Android Open Source Project
- APPNOTE.TXT — ZIP File Format Specification — PKWARE
- APK Signature Scheme v3 — Android Open Source Project
- FIPS 180-4: Secure Hash Standard — NIST