immuni-app / immuni-app-ios

Official repository for the iOS version of the immuni application
GNU Affero General Public License v3.0
547 stars 90 forks source link

[FEAT] Reproducible builds #217

Closed valerio-castelli closed 2 years ago

valerio-castelli commented 4 years ago

Reproducible builds

Table of contents

Context

This issue presents the current status of our investigation into reproducible and verifiable builds. As we mentioned in Technology Description, we're conducting active research on the topic, and there's still work to do to achieve a fully satisfactory solution. However, we'd like to start sharing some of our findings.

To simplify the discussion, we're going to break down this issue in three parts. First, we'll cover how the continuous integration process works, and what measures have been put in place to ensure the integrity of builds. Then, we'll present a methodology to allow contributors to verify App Store builds against the binaries that are published on GitHub by the continuous integration. Finally, we'll discuss our progress towards allowing contributors to make builds that match the ones produced by the continuous integration.

Continuous Integration process

Immuni's continuous integration process has been designed to be transparent and independently verifiable from the start. In this section, we'll go through the most important aspects of its design, explaining which measures have been put in place to make it so.

Every build is characterized by a unique combination of release version and build number - for example, version 1.0.0, build 944. After a build has been uploaded to App Store Connect, it cannot be removed or replaced with a different build sharing the same combination of release version and build number.

In addition, all builds that are released on the App Store are built and uploaded by the continuous integration process, automatically and without human intervention. For this purpose we rely on CircleCI, a robust continuous integration platform that supports both macOS and Docker executors, allowing us to build both the iOS and the Android variants of the app.

Each build is associated with a specific commit, which is checked out by CircleCI at the beginning of the build phase. At the end of each build, a GitHub release containing the resulting binary is created, and the binary is uploaded to App Store Connect for testing and review.

Since the entire process is fully automated and binaries can't be removed from App Store Connect after they've been uploaded, this ensures that builds can't contain any code that hasn't been publicly committed and verified by the community. However, this also implies that ensuring the integrity and transparency of the continuous integration process becomes paramount.

To ensure that integrity and transparency, we implemented several measures that help making the process independently verifiable. Among these, all continuous integration pipelines are publicly documented and maintain the full log of operations that have been performed, as well as the exact CircleCI configuration that was used. In addition, every configuration file, build script, and dependency used by the continuous integration process has been released as open source software.

Finally, we implemented an automatic CircleCI job that periodically verifies the integrity of all the critical files used by the continuous integration process. This helps us ensure that the continuous integration can't be modified to inject malicious code inside the app or to leak sensitive information that can't be made public, for instance the credentials required to upload the binaries to App Store Connect and Google Play Store.

Builds verifiability

In addition to ensuring that builds are performed in a public and verifiable way, we believe it is important to have secondary methods to check that builds only contain legitimate code. This adds an additional layer of security in case the continuous integration is compromised or bypassed.

One way to do this is to ensure that external contributors can independently verify the integrity of builds, so they can check that the binary they download from the App Store has indeed been built from a specific revision of the source code and does not contain undocumented functionalities.

Verifying iOS builds is, unfortunately, quite more complex than verifying Android builds. As explained by Telegram in their post on reproducible builds, there are two main issues that make the process cumbersome:

  1. All iOS apps, including free ones, are encrypted by Apple using FairPlay encryption upon their upload to App Store Connect. This means that the executable code of an app cannot be obtained directly from its corresponding ipa file downloaded from the App Store, but it can be extracted at runtime from an iOS device.
  2. iOS apps can only be compiled using Xcode, which requires a macOS environment. Since macOS cannot reliably run inside Docker containers, obtaining an environment that is identical to the one used by the continuous integration process can be tricky.

With the current state of things, a decrypted binary from an iOS device is needed to proceed with build verification. In addition, the following steps assume you have access to a Mac or a macOS virtual machine; some operations may be reproducible on other operating systems as well, but we haven't investigated that yet.

Requirements

In order to proceed with the build verification process, make sure you have Xcode 11.5 installed in your macOS environment. In addition, you'll need to grab the following resources:

  1. A decrypted copy of the App Store ipa binary package that you want to verify
  2. The corresponding ipa binary package built by the continuous integration process
  3. The build verification suite, composed of the main.cpp and ipadiff.py source files

The build verification suite is compatible with both Python 2 and Python 3, and should therefore be executable on a standard macOS environment without further dependencies.

Verification process

Once you have the necessary software and resources on your machine, you can proceed with the verification steps.

To simplify the process, we suggest to setup your machine as follows:

  1. All files mentioned in the requirements section must be placed in the same folder
  2. The folder must be called ipadiff and be placed within your home directory
  3. The decrypted App Store package must be called Immuni-AppStore.ipa
  4. The continuous integration package must be called Immuni-CI.ipa

At the end of these steps, the ipadiff folder should look like this:

├─ Immuni-AppStore.ipa
├─ Immuni-CI.ipa
├─ ipadiff.py
└─ main.cpp

You can now verify the integrity of the App Store build by opening a terminal window and entering the following commands:

cd ~/ipadiff
python ipadiff.py Immuni-AppStore.ipa Immuni-CI.ipa

Assuming everything works correctly, the output should be similar to this:

IPAs are equal
    IPAs contain .car (Asset Catalog) files that are compiled by the App Store and can't currently be checked:
        Assets.car
    IPAs contain .nib (compiled Interface Builder) files that are compiled by the App Store and can't currently be checked:
        LaunchScreen.storyboardc/UIViewController-01J-lp-oVM.nib
        LaunchScreen.storyboardc/01J-lp-oVM-view-Ze5-6b-2t3.nib

How does this work?

Once a package has been uploaded to App Store Connect, it undergoes a process that prepares it for distribution. This process may include several steps, but it can generally be summarized as follows:

  1. The package is checked by Apple to verify that it has been signed with App Store distribution certificates, that all the necessary entitlements have been included, and that the code has been compiled correctly
  2. The Info.plist file inside the package is modified to include the additional ITSDRMScheme property
  3. The package is encrypted using Apple's FairPlay DRM technology

In general, iOS apps may be compiled either to the target architecture instruction sets or to an intermediate LLVM bitcode representation. If the submitted package has been compiled to bitcode, Apple takes care of compiling it for all supported target architectures, producing optimized binaries through a technique called binary slicing. While compiling to bitcode would lead to shorter build times on the developer side, the continuous integration binary would be fundamentally different from the one distributed through the App Store, making any comparison between the two impossible. For this reason, Immuni is compiled directly to the target architecture instruction sets.

Like all applications published after the introduction of iOS 9, Immuni builds undergo a process of app thinning during the App Store distribution process which creates different optimized packages for different families of iOS devices. As builds are not submitted in bitcode format, the app thinning process is limited to the elimination of unnecessary assets from the distributed package and does not alter the executable code. In addition, the package exported by the continuous integration process is universal and contains the necessary assets for all families of iOS devices. This allows external contributors to verify their decrypted App Store binary against the original package, regardless of which device they obtained it from.

The changes introduced by Apple to the uploaded package mean that a verbatim comparison between the decrypted App Store package and its original continuous integration copy is not possible. However, a successful comparison can be obtained with minor modifications to the packages that do not alter the executable code. To this end, we provide a build verification suite that is heavily based on the work made by Telegram for their own reproducible builds effort.

The first step is to remove from the comparison all files which can't be accurately compared. This means, in particular, ignoring the assets and the nib files, as well as all files and directories related to codesigning. Note that none of these files contain executable code, so their removal does not affect the integrity of the comparison.

The second step is to remove from the Info.plist the additional keys that are injected by the Apple servers upon the upload of the package; this refers in particular to the ITSDRMScheme property. Although not strictly necessary for this phase, we also remove the UISupportedDevices, DTAppStoreToolsBuild, MinimumOSVersion, BuildMachineOSBuild, and CFBundleVersion properties from the file, as their removal can simplify the process of supporting reproducible builds. Also in this case, none of these modifications involve executable code, so the integrity of the comparison is preserved.

At this point, provided that the supplied App Store package has been decrypted, all changes to the binary introduced by Apple's distribution process have been reverted. However, due to the way the decrypted App Store package is obtained, a further step is necessary to successfully compare it to its original counterpart.

As iOS apps are decrypted only when they're loaded into memory at runtime, the process of obtaining a decrypted version of an App Store package implies that the binary must be extracted from a snapshot of the device's RAM. As iOS implements Address Space Layout Randomization (ASLR), some of the addresses and offsets of the obtained binary will not match the ones included in the original binary.

To get around this, the build verification suite includes a program that replaces the affected addresses and offsets with zeros in both binaries. The affected commands and segments of the binary are the LC_CODE_SIGNATURE, LC_SEGMENT_64, __LINKEDIT, LC_ID_DYLIB, LC_UUID and LC_ENCRYPTION_INFO_64. For a more thorough explanation of these sections, please refer to Apple's implementation of the iOS Mach-O loader.

As all differences have now been accounted for, the two packages can now be successfully compared.

Build reproducibility

A further way to ensure that the builds delivered through the App Store accurately reflect the source code published in the official repositories is to allow external contributors to compare them with their own builds.

This introduces additional challenges on top of the build verification process explained above. More specifically:

  1. App Store builds are signed with signing certificates issued to the Ministero della Salute. As the code signing process of iOS binaries involves the addition of the signing certificate and the provisioning profiles inside the binary, comparing an App Store build with a third party build would inevitably lead to a mismatch.
  2. The build environment must be the same as the one used by CircleCI. We currently don't know whether this also includes the version of macOS used to perform the build, but it almost certainly includes the version of Xcode. In fact, each release of Xcode is shipped with its own version of the clang and swiftc compilers, which may produce different code from the versions included in other releases of Xcode as more optimizations are added.
  3. The build configuration must be the same as the one used by CircleCI. This means, in particular, making sure that the build is compiled for Release and that the package is archived as if it were to be distributed through the App Store.

So far, we've investigated a few mitigations to make build reproducibility a reality. While we haven't got there yet, we have encouraging evidence that it should be possible to achieve it with the current technological stack.

Requirements

In order to attempt a reproducible build, make sure your machine is configured as described in the Requirements section of the Build verifiability process. If it is possible, use a macOS environment equipped with the same version of macOS as the one included in the Xcode 11.5.0 CircleCI image - which, at the time of writing, is macOS 10.15.4.

You'll also need to have an Apple Developer account, register an application with a dedicated bundle identifier, and obtain a valid signing certificate and provisioning profile for it. This is a required step in order to sign and export an ipa package. The account can be a free one, as the ability to export a package for App Store distribution is not needed to perform build verification.

Please note that you won't be able to use the official bundle identifier of Immuni - it.ministerodellasalute.immuni. In fact, the bundle identifier must be unique on the App Store, and the provisioning profile can only be emitted for an app belonging to your own developer account. We're investigating the possibility of releasing dummy provisioning profiles and signing certificates for this purpose, but they're not available at this time.

In addition, you'll need to make a few modifications to the continuous integration pipeline. These modifications are necessary to mimic the process of making an App Store build without actually making one, as exporting an App Store build would require being enrolled in the paid Apple Developer Program.

To this end, make sure you've checked out the commit that you want to build. Then, create a text file called validation.plist within the CI/Export folder with the following content:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>export</string>
<key>method</key>
<string>development</string>
<key>signingStyle</key>
<string>manual</string>
</dict>
</plist>

In addition, add the following lane to the fastlane/Fastfile file:

lane :reproducible_build do
  build(
    configuration: 'Release',
    distribution_method: 'validation'
  )
end

Finally, modify the project.yml file so that it uses the development signing identity and entitlements for Release builds:

CODE_SIGN_ENTITLEMENTS: App/Resources/Entitlements/Development.entitlements
CODE_SIGN_IDENTITY: iPhone Developer
DEVELOPMENT_TEAM: <your development team>
PROVISIONING_PROFILE_SPECIFIER: <your provisioning profile>

Build process

Once you've modified the project's files as described above, install your signing certificate and provisioning profile through fastlane like so:

fastlane run import_certificate certificate_path:development.p12 certificate_password:<password>
fastlane run install_provisioning_profile path:development.mobileprovision

You can now attempt to perform a reproducible build by executing the following commands:

export EXCLUDE_DEV_TOOLING='1'
CI_MODE=1 make immuni
pod install
fastlane ios reproducible_build

If everything works correctly, at the end of the process you should find an ipa package in the Products directory of the git repository. However, the package can't be compared with its App Store variant yet - for that, a couple of modifications are necessary.

Since the code signing identity is going to be different from the one of the App Store binary, it is necessary to strip the code signing from both binaries. To do so, you can apply these instructions both to the decrypted App Store package and to the package of your own build:

  1. Rename the target ipa package so that it has a zip extension
  2. Uncompress the zip package
  3. Execute the following terminal command to remove the code signature:
    codesign --remove-signature Payload/Immuni.app/Immuni
  4. Compress the Payload folder back into a zip package
  5. Rename the obtained zip package so that it has an ipa extension

At this point, the two packages are ready for comparison. At the time of writing, however, the comparison will likely fail, due to differences in the way Immuni is compiled on different machines. We believe the issue to be related to one or more flags of the project.yml file and we're currently investigating solutions.

agos commented 4 years ago

are you sure reproducible builds are needed, or even useful? here's some thoughts on the security advantage (or lack thereof) of reproducible builds written much better than I could, and that's before the whole App Store process is taken into account

AndreasGassmann commented 3 years ago

I strongly disagree with this article. I think it was written about reproducibility of packages and not apps. And if you read the whole article, he does mention the benefits of reproducible builds, but dismisses them because it doesn't cover all potential attack vectors on build systems:

Q. Whether it’s useful for end users or not, it will allow experts to monitor for compromised build servers producing tampered builds.

I think this is true, but there are other attacks against compromised build servers, all of which are more common than producing tampered builds.

More often, attackers want signing keys so they can sign their own binaries, steal proprietary source code, inject malicious code into source code tarballs, or malicious patches into source repositories.

Reproducible builds don’t help with any of those problems.

Of course he is right, but what's his point? They are separate problems and require separate solutions. I don't understand why he suggests that one has to do with the other.


@valerio-castelli Thank you for this comprehensive write up. Have you made any progress in this regard?

I tried to reproduce the telegram app myself, but Step 9 seems to be missing. Do you have any resources on how to obtain a decrypted version of the app?

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.