Mobile Computing · Mobile Release
Reproducible Mobile Builds and Independent Artifact Verification
Which build inputs leak into a mobile binary, how path and timestamp normalisation makes two builds comparable, and what a third party can actually prove by rebuilding.
The claim a signature makes is narrow: these bytes were signed by this key. It says nothing about where the bytes came from, and for an app distributed through a store the gap is not hypothetical. The publisher uploads a bundle, the store re-processes it, and the artifact the user installs has been through at least one transformation that the publisher did not perform. Independent verification asks whether the installed artifact corresponds to the published source, which requires that rebuilding the source produce the same bytes.
Where nondeterminism enters a mobile build
A build is a function from inputs to an artifact, and it is reproducible when the function is pure. Real toolchains are not pure. Four categories of impurity dominate.
Absolute paths. Compilers embed source file paths into debug information and into strings used by assertion and logging macros. Two machines with the source at different paths produce different bytes. The standard remedy is a path mapping applied at compile time, so that /home/alice/project/src and /build/worker-3/src both become a fixed prefix such as /src. GCC and Clang both accept a prefix-mapping option for this, and the mapping must cover the macro __FILE__ as well as the debug line table, which is why the option that remaps only debug info is not sufficient.
Timestamps. Archivers record modification times, and some compilers embed a build date in a version string. The fix is to clamp every timestamp to a fixed epoch, which both ZIP and tar formats tolerate, and to remove any generated timestamp from the source rather than relying on a normalisation step downstream.
Iteration order. A linker that resolves symbols from a hash table, a build system that globs a directory, and a resource compiler that walks a map can all produce output whose order depends on the hash function’s seed or on filesystem enumeration order. Go’s build system randomises map iteration by design, and C++ linkers that emit sections in hash order produce a different layout per run. Sorting every collection that reaches the output is the only general fix.
Parallelism and scheduling. If two compilation units both write to a shared generated file, or if the build emits outputs in completion order, the artifact depends on core count and scheduling. This is the category that survives all the others and shows up only under load, which is why reproducibility is tested by building twice on machines with different core counts rather than twice on the same machine.
The archive layer is where most of the variance lives
For a mobile artifact, the container itself is a source of difference that has nothing to do with compilation. A ZIP archive stores entries in the order they were added, with per-entry timestamps, optional extra fields, and a data descriptor whose presence depends on whether the writer knew the compressed size in advance. Two identical trees zipped by different tools produce different archives.
Normalising means fixing each of those: entry order sorted by name, timestamps set to a constant, extra fields stripped, compression level pinned, and the deflate implementation pinned. The last point is easy to miss. Deflate has many valid encodings of the same input, and different implementations choose different match lengths and block boundaries. Two builds using different zlib versions can produce archives that decompress identically and differ byte for byte.
For APKs there is a further layer: signing. A signature is by nature non-reproducible if it uses a randomised scheme, and even a deterministic scheme over a certificate whose validity window differs produces a different result. The usual resolution is to compare the unsigned artifact, or to verify that the signing block is the only difference — which requires the comparison tool to understand the block’s structure rather than to treat the files as opaque.
import hashlib, struct
def unsigned_digest(apk_path: str) -> str:
"""Digest of everything except the signing block, as scheme v2 defines it."""
data = open(apk_path, "rb").read()
eocd = data.rfind(b"PK\x05\x06")
cd_off = struct.unpack_from("<I", data, eocd + 16)[0]
h = hashlib.sha256()
if data[cd_off - 16:cd_off] != b"APK Sig Block 42":
h.update(data[:cd_off]); h.update(data[cd_off:eocd]); return h.hexdigest()
size = struct.unpack_from("<Q", data, cd_off - 24)[0]
block_start = cd_off - size - 8
h.update(data[:block_start]) # entries, in file order
h.update(data[cd_off:eocd]) # central directory
tail = bytearray(data[eocd:eocd + 22])
struct.pack_into("<I", tail, 16, block_start) # EOCD points at the block
h.update(tail)
return h.hexdigest()
Only one field needs rewriting, and the reason is worth following. Central directory entries store the offset of their local header relative to the start of the file, and every local header sits before the block, so those offsets are unaffected by its removal. The EOCD’s central directory offset does change, and it is rewritten to the block’s start rather than to the removed block’s absence — so the signed bytes describe a file that claims a block at that position, and stripping the block changes a signed field rather than producing a clean unsigned archive.
What a rebuild proves and what it does not
If a third party rebuilds the source and gets the same artifact, several claims follow. The published binary corresponds to the published source, at least for the parts the build actually compiles. The build did not inject code that is absent from the source. The toolchain behaved as expected, given that the rebuild used the same toolchain.
Several claims do not follow. Reproducibility does not prove the source is free of defects, nor that the build environment was free of tampering — a compromised compiler can produce a matching binary from a compromised source, and the classic counter is a diverse double compile with a second toolchain, which detects a compiler that injects a payload only when the payload is not in the source. It also does not cover what the build fetches at run time, which for a mobile app can include remotely configured flags that change behaviour without changing bytes.
The strongest practical statement is conditional: given the same source, the same declared toolchain, and the same declared environment, the artifact is identical. Making that condition checkable is what build attestation adds — a signed statement from the build system recording the inputs, the toolchain versions, and the output digest, so a verifier can check the statement against its own rebuild rather than trusting a prose description of the build.
The mobile-specific obstacle: the store re-signs
An app delivered through a store is not the artifact the developer built. The store may re-sign with its own key, re-split a bundle for the target device, apply its own compression, and in some cases patch the binary. The bytes on the device therefore cannot match the developer’s build output, and a reproducibility claim about the developer’s artifact is a claim about an intermediate product.
There are three ways to handle this. The first is to compare at the level the developer controls: publish the digest of the uploaded artifact and verify the store’s processing separately, which requires the store to expose what it did. The second is to reproduce the store’s transformation, which is possible in principle for splitting and re-signing but requires the transformation to be deterministic and documented. The third, and the one that is actually available, is to verify a build that is installed without store processing — a direct install of a self-signed artifact, which is what enterprise and internal distribution do.
This is why reproducibility efforts in the mobile space concentrate on builds distributed outside the store, and why the store’s own attestation is a different mechanism answering a different question. The publisher’s signature and the store’s signature are two statements about the same code, and neither implies the other.
Verification as a release gate
Reproducibility is cheap to check continuously and expensive to check once. A build that has drifted for six months has an unknown number of independent causes, and each has to be found by bisecting the artifact. A build that is checked on every commit fails at the change that introduced the impurity, and the diff is one commit wide.
The gate has to build twice, and the two builds must differ in something that matters: different absolute path, different user, different hostname, different core count, and ideally a different wall-clock time. Building twice on the same machine with the same environment catches nothing, because every impurity that is a function of the environment produces the same value both times.
Comparing the results needs a tool that reports where they differ rather than only that they differ. A byte offset in a compressed stream is useless; a diff at the level of archive entries, with the entry name and the differing region, points at the cause. The practical loop is: compare, find the entry, decompress it, diff the decompressed content, and identify whether the difference is a path, a timestamp, an ordering, or a value.
The residual risk is the toolchain itself. Pinning compiler and library versions makes the build reproducible today and brittle tomorrow, because a rebuild years later needs the same toolchain, which means the environment has to be archived along with the source. Reproducing a build from a container image whose digest is recorded is the usual answer, and it moves the trust question to the image rather than removing it.
Limits worth stating plainly
A reproducible build is a statement about a single artifact and a single input set. It does not compose: an app that bundles a third-party SDK has no reproducibility guarantee for that SDK’s bytes unless the SDK’s own build is reproducible and its provenance is recorded. Most mobile apps are assembled from binary dependencies, which means the reproducible fraction is the code the publisher compiles, not the artifact as a whole.
Nor is reproducibility a security control on its own. It reduces the set of things a compromised build machine can do undetected, and it makes a supply-chain substitution visible to anyone who checks. It does not prevent a compromised source from producing a matching binary, and it does not help a user who does not rebuild. Its value is concentrated in the small number of parties who do rebuild and publish the comparison — which is the same structure as every other independent-audit mechanism, and it fails when nobody audits.
References
- Reproducible Builds — Documentation — reproducible-builds.org
- Debian — Reproducible builds — Debian Wiki
- SLSA Specification — Open Source Security Foundation
- GNU Compiler Collection — Overall Options — GNU Project