Secure PHAR Automation
For a variety of reasons, I've been working on a utility that is best distributed via PHAR file. As has been noted by others (archive.is link, due to lack of availability of original site), PHAR distribution, while useful, is not without security concerns, and I decided to investigate how to securely create, distribute, and update PHAR utilities as part of this exercise.
This is an account of my journey, as well as concrete steps you can take to secure your own PHAR downloads.
The Roadmap
The steps outlined by Pádraic Brady in the afore-linked post were essentially:
- Distribute the PHAR over TLS-secured HTTPS.
- Sign your PHAR with a private key.
- Manage self updates securely (i.e., the updates must be over TLS, and updated PHAR files should be signed using the same private key).
As such, I figured the plan should be:
- Use GitHub Pages for distribution. This gives me essentially free hosting, and free TLS.
- Create an OpenSSL key, use it to sign the package, and provide the public key for download.
- Have functionality built-in to the PHAR for updating and rolling back.
- Automate creation of the PHAR, as well as pushing it and the version information to the site.
Seems simple enough, right?
It would have been, had I been able to find examples of each of the steps. In the end, I spent an afternoon testing different strategies, and finally came up with what follows.
Create an OpenSSL Key
The first step is to create an OpenSSL private key. This will be used to sign the packages.
$ openssl genrsa -des3 -out phar-private.pem 4096
The above will prompt you for a passphrase, which is used to encrypt the key.
For purposes of automation, however, you may not (in fact, will not, as you'll see later) be able to enter the passphrase. As such, you'll need to strip it.
$ cp phar-private.pem phar-private.pem.passphrase-protected
$ openssl rsa -in phar-private.pem -out phar-private-nopassphrase.pem
$ cp phar-private-nopassphrase.pem phar-private.pem
The second step will prompt you for the passphrase. The resultant key will have it stripped. You can keep the passphrase-protected version if you wish; however, it's functionally equivalent to the new key. If you keep it, place it somewhere safe.
From here, create a .travis/
subdirectory in your project, put the private key in
it, and then add that file to your project .gitignore
:
$ mkdir .travis
$ mv phar-private.pem .travis/
$ echo ".travis/phar-private.pem" >> .gitignore
We're telling git to ignore the key, as we don't want to push it unencrypted to our repository!
Use Box to create the PHAR
Now that we have a key, we can think about creating our PHAR file.
While PHP provides a ton of functionality around PHARs, the problem is that the manual is not terribly detailed, and this particular section has often gone out-of-date. On top of that, to build even a relatively simple PHAR that has an executable stub takes a ton of knowledge.
So, let others do the work for you. Specifically, the Box Project. The team behind the Box Project has done the hard work for you; all you need to do is create a configuration file that details what files are used, what compression to use, what signing mechanism to use (if any) and where the key is located, etc.
The link above details retrieving box, but the basics are:
$ curl -LSs https://box-project.github.io/box2/installer.php | php
which will leave an executable box.phar
in the working directory. I usually
put this in my $PATH
and alias box
to it.
Box, like many other PHP utilities, allows you to create either a configuration
file, or a "dist" configuration file. I like to do the latter, as it then lets
me copy it locally to provide modifications/customizations. Here's a basic
box.json.dist
file containing the options for creating an OpenSSL-signed,
gzipped, executable package:
{
"algorithm": "OPENSSL",
"chmod": "0755",
"compression": "GZ",
"directories": [
"src"
],
"files": [
"LICENSE.md"
],
"finder": [
{
"name": "*.php",
"exclude": [
"tests",
"test"
],
"in": "vendor"
}
],
"git-version": "package_version",
"intercept": true,
"key": ".travis/phar-private.pem",
"main": "bin/command.php",
"output": "command.phar",
"stub": true
}
Some notes on the various options:
- "algorithm" and "key" go hand-in-hand. The algorithm indicates what package
signing algorithm to use, and the "key" is the path on the filesystem to the
key; relative paths are relative to the
box.json.dist
file. - "compression" indicates the compression algorithm to use; in this case, I used gzip.
- "git-version" is a string to look for in files; technically, it looks for
@package_version@
, and not just the string. When discovered, it replaces that string with the sha1 of the most recent commit. - "directories" is used to specify directories where all files should be included; "files" is used to specify individual files to include. Usually I use "files" for anything not in "directories", like the LICENSE file.
- "finder" can be used in a similar fashion to "directories" and "files", but
gives you the ability to provide filtering rules; it uses the Symfony Finder
component, which means
that the rules you build will follow that component's configuration syntax. In
the example above, I'm telling it I want to include the
vendor/
directory, but to only include PHP files, and to exclude files in the directories "test" or "tests". Doing so greatly reduces the size of the generated PHAR. - "main" is the name of the file containing the script to execute when the PHAR is invoked. Obviously, the name will vary based on your application.
- "stub", when
true
, indicates thatthe default stub based on the "main" script should be used; you can specify another stub file as well, if desired. I've used Box several times, and found that this needs to be booleantrue
when I'm having it encapsulate a command-line script. - "output" is the name of the file to generate.
- "chmod" indicates the file mode mask to set on the generated file.
Obviously, configure this according to your needs, and add or remove
configuration as befits your project. The schema
file is your
friend when determining what should be included; much of the information is also
available via box.phar help build
.
Once you have created the file, you can attempt a build:
$ box build -vv
(-vv
indicates "verbosity"; I do this so I can see errors if they occur.)
Make the build leaner!
The command may take a while. In fact, if you have a lot of dependencies, it will take a while. I found it was best to strip any development-only dependencies prior to running a build:
$ composer install --no-dev
I find this results in a far faster build, with a much smaller file size.
When the build is successful, you'll end up with two files:
-
command.phar
-
command.phar.pubkey
The name will be based on what you specified for "output" in the configuration. The second file is the OpenSSL public key derived from your private key. Users will need to have both files available, as PHP's PHAR functionality will verify the archive against the public key on every invocation. This is what ensures a secure distribution!
Now that you have the files, add them to your .gitignore
:
$ echo "command.phar" >> .gitignore
$ echo "command.phar.pubkey" >> .gitignore
We don't want the PHAR in the master branch; this is the branch used to create the PHAR itself. By excluding it, we can do some automation later.
Generate a version file
Now that we have the PHAR, we need to provide a way of indicating what version we have. The simplest way to do that is to take its sha1sum and write it to a file:
$ sha1sum command.phar > command.phar.version
We'll use this later to automate self-updates. Just like the PHAR file itself and its public key, we'll exclude it from the branch:
$ echo "command.phar.version" >> .gitignore
Create the gh-pages branch
At this stage, we have a PHAR file that is signed with a private OpenSSL key, a public OpenSSL key for verifying the signature, and a version file we can use later for triggering updates. It's time to publish those.
As noted above, we want to publish to a TLS-enabled site. GitHub provides that
infrastructure for us via GitHub Pages. These are available via any public
repository that publishes a gh-pages
branch. The obvious conclusion is: let's
add that branch to our current repository!
That said, the new branch really shouldn't have the build tools and code.
First, let's make sure you've committed everything you need on the master branch:
$ git add .gitignore box.json.dist
$ git commit -m 'Build tools for PHAR file'
Now we can create what's called an "orphan" branch — a branch with no history and no parents:
$ git checkout --orphan gh-pages
$ git rm -rf .
We perform the git rm -rf .
command in order to remove any files previously
committed. One nice side effect is that untracked files — such as our
generated PHAR, public key, and version file — are untouched by this
operation!
So, let's add them:
$ git add composer.phar*
It would be good to create a landing page as well, with links for downloading
the PHAR file and the public key (don't worry about the version file for now).
Create an index.html
file in the root of the project, and add that as well, as
well as any CSS or JavaScript files you need for it. I personally used
Bootstrap from a CDN for this, which gives a
reasonable default look for the package.
Once all your files are added, execute:
$ git commit -m 'Initial gh-pages files'
and then push to your GitHub repository.
Within a minute or so, you should be able to browse to https://<your username or org>.github.io/<your repo>/
.
Write self-update/rollback commands
Now that we have a process for creating a PHAR, how will users update or rollback?
Fortunately, Pádraic has a solution for that as well: PHAR Updater. This handy library provides functionality for replacing the PHAR, and has built-in support to verify the signature of the replacement with the public key. For it to work, the PHAR, public key, and version file must all be accessible over TLS/SSL.
First, add the utility to your package:
$ composer require padraic/phar-updater
Regardless of how you write your console commands in PHP, your self-update command will execute something like the following:
use Humbug\SelfUpdate\Updater;
$updater = new Updater();
$updater->getStrategy()->setPharUrl($urlToGithubPagesPharFile);
$updater->getStrategy()->setVersionUrl($urlToGithubPagesVersionFile);
try {
$result = $updater->update();
if (! $result) {
// No update needed!
exit(0);
}
$new = $updater->getNewVersion();
$old = $updater->getOldVersion();
printf('Updated from %s to %s', $old, $new);
exit(0);
} catch (\Exception $e) {
// Report an error!
exit(1);
}
It's really that simple! The defaults assume an OpenSSL-signed PHAR file, and
that the public key is present locally in a file named <name of phar>.pubkey
.
If the version is different, it updates; if not, it doesn't. Exceptions
typically occur for things like:
- File permissions.
- Inability to reach the remote PHAR or version files.
- Inability to validate the downloaded PHAR.
- Inability to perform TLS negotation.
Regarding this latter, padraic/phar-updater includes
padraic/humbug_get_contents, which is supposed to iron out TLS issues on PHP
versions < 5.6. I found in practice, however, that when performing the update,
if I didn't use a PHP 5.6 version, it consistently failed, indicating TLS
negotiation issues. Supposedly you can fix these by downloading
http://curl.haxx.se/ca/cacert.pem and setting
openssl.cafile=/path/to/cacert.pem
in your php.ini
file.
So, self-update is taken care of; what about rollback?
When a self-update is performed using padraic/phar-updater, it writes the
original PHAR to command-old.phar
in the same directory as the original PHAR
file. If that file is available, you can write a rollback routine like the
following:
use Humbug\SelfUpdate\Updater;
$updater = new Updater();
try {
$result = $updater->rollback();
if (! $result) {
// report failure!
exit(1);
}
exit(0);
} catch (\Exception $e) {
// Report an error!
exit(1);
}
Again, quite easy!
Checkout your project's master
branch, add the above commands, and re-build
your PHAR…
Wait, shouldn't we automate that last step? It'd be really nice if we could push to master, and have the new PHAR show up automatically on our gh-pages branch!
Enable Travis-CI for the repository
How do we automate? Via continuous integration and deployment, of course!
For this step, I'm choosing Travis-CI. It's free for open source projects, but also has a paid, private tier if you need it. Its dockerized builds trigger typically within seconds of pushing your code, and the environment is built and your tests run in often under 30 seconds. It's a great choice for this.
As a CI service, it provides a number of stages that trigger, often
conditionally. We're going to use one of these, after_success
, to build the
PHAR, update the version file, and push them to our gh-pages branch.
First, though, we need to enable Travis-CI for our repository. You will need to do the following:
- Register for a Travis-CI account if you haven't already. The homepage will guide you there.
- Once you have an account and have logged in, go to your profile page (clicking your icon in the top right takes you there).
- Find your repository in the list, and toggle the switch to enable it. (You may need to sync your repositories if your project is new within the last day; there's typically a "Sync" button next to where your name appears above the repository list.)
From here, you should add a .travis.yml
file to your project, if you haven't
already. Here's a template:
sudo: false
language: php
cache:
directories:
- $HOME/.composer/cache
- vendor
matrix:
fast_finish: true
include:
- php: 5.5
- php: 5.6
env:
- EXECUTE_DEPLOYMENT=true
- php: 7
- php: hhvm
allow_failures:
- php: hhvm
before_install:
- phpenv config-rm xdebug.ini
- composer self-update
install:
- travis_retry composer install --no-interaction
- composer info -i
script:
- ./vendor/bin/phpunit # If you have tests
notifications:
email: true
Commit and push that file, and you should see your first build appear on Travis-CI.
Create an SSH deploy key
If we want Travis-CI to push to our gh-pages branch, we'll need to provide it with a deployment key.
First, create a new SSH key:
$ ssh-keygen -t rsa -b 4096 -C "<your email address>"
This will prompt you for where you want to put the new key files, and what they
should be named; I use descriptive names in these cases, such as the repository
name, and the selected encryption type: "component_installer_rsa" . Usually
$HOME/.ssh/
is a good location to store them.
Next, we'll provide the public key to GitHub. Open the public key generated
(usually <key name>.pub
in a visual editor with clipboard support, and copy
the entire file. Then go to https://github.com/<your username or org>/<your repo>/settings/keys
. On that page, click the button Add deploy key
, give your
key a name (I used <repo name> for Travis-CI
), and paste in the key where
indicated. Finally, click the box enabling write permissions; we want to be able
to push commits with this key! Confirm and save it.
Finally, we need to copy the private key into the project. Don't worry; we're not going to commit it yet; in fact, we're going to tell git to omit it:
$ cp $HOME/.ssh/<repo>_rsa .travis/build-key.pem
$ echo ".travis/build-key.pem" >> .gitignore
At this point, we now have two files in .travis/
, neither of which git will
commit to the repository: phar-private.pem
and build-key.pem
. And, somehow,
Travis-CI needs to get access to them.
Archive and encrypt the secrets
Travis-CI provides a number of facilities for encrypting secrets that you wish to utilize during the build process. In our case, we need to provide encrypted files.
Interestingly, due to some issues with OpenSSL and the way the support is implemented in Travis-CI, you can only encrypt a single file. Thus, if you have multiple files, you need create an archive of them and encrypt that.
$ cd .travis
$ tar cvf secrets.tar *.pem
$ cd ..
This will create the file .travis/secrets.tar
.
Now, we need to encrypt the file. To do this, you will need to install the
travis
gem:
$ gem install travis
and then login:
$ travis login
Once you've done that, you can encrypt the secrets.tar
file:
$ travis encrypt-file .travis/secrets.tar .travis/secrets.tar.enc --add
This will create a new file, .travis/secrets.tar.enc
, and add an entry to your
.travis.yml
's before_install
section that will decrypt the file; this means
that your code and scripts on Travis-CI can then rely on .travis/secrets.tar
being available.
Note for the Type-A personalities out there
When you use the
--add
flag andtravis
rewrites your.travis.yml
file, it strips out any whitespace you've added.
We'll add the .travis/secrets.tar.enc
file to the repository, and omit
.travis/secrets.tar
:
$ git add .travis/secrets.tar.enc
$ echo ".travis/secrets.tar" >> .gitignore
$ git add .gitignore
When a build is triggered on Travis-CI now, it will decrypt this file before any of our build processes are triggered, allowing us access to those secrets!
Write a deployment script
Now that we have our secrets securely available on Travis-CI, we can figure out what deployment might look like:
- We'll want to remove development-only dependencies.
- We'll want to extract the secrets from the tarball.
- We'll want to start the SSH agent with our deployment key.
- We'll want to setup our Git identity. (In my experiments, I discovered that GitHub rejected pushes from valid deployment keys that did not include a full name and email.)
- We'll need to add a git remote using the SSH-enabled repository path.
- We'll want to fetch the Box Project PHAR file.
- We'll want to create the PHAR using Box.
- We'll need to generate a new version file from the re-generated PHAR.
- We'll need to check out the gh-pages branch, and add the PHAR and version file.
- We'll need to push the changes to GitHub.
I do all but the first step in a script, which I put in bin/deploy.sh
:
###!/bin/bash
### Unpack secrets; -C ensures they unpack *in* the .travis directory
tar xvf .travis/secrets.tar -C .travis
### Setup SSH agent:
eval "$(ssh-agent -s)" #start the ssh agent
chmod 600 .travis/build-key.pem
ssh-add .travis/build-key.pem
### Setup git defaults:
git config --global user.email "<your email here>"
git config --global user.name "<your name here>"
### Add SSH-based remote to GitHub repo:
git remote add deploy git@github.com:weierophinney/component-installer.git
git fetch deploy
### Get box and build PHAR
wget https://box-project.github.io/box2/manifest.json
BOX_URL=$(php bin/parse-manifest.php manifest.json)
rm manifest.json
wget -O box.phar ${BOX_URL}
chmod 755 box.phar
./box.phar build -vv
### Without the following step, we cannot checkout the gh-pages branch due to
### file conflicts:
mv component-installer.phar component-installer.phar.tmp
### Checkout gh-pages and add PHAR file and version:
git checkout -b gh-pages deploy/gh-pages
mv component-installer.phar.tmp component-installer.phar
sha1sum component-installer.phar > component-installer.phar.version
git add component-installer.phar component-installer.phar.version
### Commit and push:
git commit -m 'Rebuilt phar'
git push deploy gh-pages:gh-pages
You'll note that this script makes reference to bin/parse-manifest.php
; this
is a PHP script that parses the version manifest file for Box to find the
download URL of the latest box.phar
. It looks like this:
<?php
chdir(__DIR__ . '/../');
$fallbackUrl = 'https://github.com/box-project/box2/releases/download/2.6.0/box-2.6.0.phar';
if (! isset($argv[1]) || ! is_file($argv[1])) {
return $fallbackUrl;
}
$manifestJson = file_get_contents($argv[1]);
$files = json_decode($manifestJson, true);
if (! is_array($files)) {
echo $fallbackUrl;
exit(0);
}
foreach ($files as $file) {
if (! is_array($file) || ! isset($file['version'])) {
continue;
}
if (version_compare($file['version'], '2.6.0', '>=')) {
echo $file['url'];
exit(0);
}
}
echo $fallbackUrl;
exit(0);
Essentially, it attempts to parse the manifest, and, on any failure, uses a known-good URI. You could, of course, just use the known-good URI always.
Why not use the Box installer?
I'm trying to demonstrate a secure toolchain. As Pádraic outlines in his post, installer scripts can introduce remote code execution vulnerabilities, particularly if directly piped to the PHP executable. In this particular case, the Box
installer.php
is using an insecure URI for downloading themanifest.json
(I've proposed a patch for this). As such, I'm downloading the manifest over SSL, manually parsing it, and then downloading the PHAR file from the parsed results.
Make the deployment script executable, add both scripts to your repository, and commit!
Add the script to travis
Now we need to tell Travis-CI to execute this script. We want it to run:
- Only for one build environment. (No need to push multiple times for the same commit!)
- Only if the build is successful.
- Only for builds on the master branch.
- Only if the build is not for a pull request.
If you used the .travis.yml
file I provided earlier as a template, you likely
noted the section where I define the env variable $EXECUTE_DEPLOYMENT
. This is
what enforces the first point (only run for one build environment).
For the second point, we're going to define an after_success
section in the
configuration; this ensures it does not trigger if our other tasks fail (such as
unit tests, CS checks, etc.).
For the third and fourth points, Travis-CI provides some environment variables to help us:
-
$TRAVIS_BRANCH
indicates the branch. However, in the case of a pull request, this will be the base branch against which the pull request was made. As such, we also need: -
$TRAVIS_PULL_REQUEST
. The value of this is the pull request ID when present; otherwise, it's the string "false".
Putting it all together results in the following additions to the .travis.yml
file:
after_success:
- if [[ $EXECUTE_DEPLOYMENT == 'true' && $TRAVIS_BRANCH == 'master' && $TRAVIS_PULL_REQUEST == 'false' ]]; then composer install --no-dev ; fi
- if [[ $EXECUTE_DEPLOYMENT == 'true' && $TRAVIS_BRANCH == 'master' && $TRAVIS_PULL_REQUEST == 'false' ]]; then ./bin/deploy.sh ; fi
Each line only executes if we are on the designated environment (we chose 5.6 for this example), on the "master" branch, for non-pull-request pushes. The first line removes the development dependencies from the tree, and the second executes our deployment script.
Note on after_success vs deploy
Travis-CI has another event,
deploy
, which is often touted as the appropriate place to perform, well, deployments, which is essentially what we're doing in the above.What I found, however, is that the workflow didn't work well when you have cached or encrypted files.
The deploy event, when triggered, stashes changes, does a clean checkout, and then tries to restore from the stash. What I observed was that my cached composer files (the
composer.lock
file andvendor/
directory) created conflicts when applying the stash, which caused my deployment script to never trigger. Another time I observed that the decrypted version of my secrets disappeared with the new checkout.If any readers have any feedback on this, I'd love to hear it!
Push and watch it work
Hopefully, if you've been following along this far, you'll see that on your next push with a successful build, your gh-pages branch will get a new commit, with an updated PHAR and version file!
The workflow for consumers will then be:
- Go to your gh-pages site and download the PHAR file and public key.
- Periodically execute
<name of phar file>.phar self-update
to update their installation (assuming you named the self-update command "self-update"). - If desired, they can later rollback to a previous version using
<name of phar file>.phar rolback
(assuming you named the rollback command "rollback").
All of this can be done securely, because you've setup a secure workflow:
- The PHAR file, public key, and version file are all secured via TLS.
- The PHAR file is signed using an OpenSSL private key, and can be verified using its public complement.
In your own workflow, you're only pushing encrypted secrets to the repository,
and the keys for those are known only to you and Travis-CI. (In fact, you only
"know" them through the travis
gem!) If your deployment key is compromised,
you can revoke it from GitHub. If you feel the signing key has been compromised,
you can create a new one, and notify your users that they need to re-download
the PHAR and public key.
Still under research
While the above workflow is tested and works, I have one item I'm still unhappy with: I'd really like it if the deployment could be delayed until I know all environments have completed successfully. If anybody could assist me with that, I'd love to hear from you!
Updates
Below is a list of updates made to this post since the time of writing:
- 2015-12-15: Changed references to
composer update
to readcomposer install
, per a comment from Christophe Coevoet. - 2015-12-15: Changed OpenSSL key generation example to use 4096 bits instead of 2048, per a comment from sf_tristanb.
- 2015-12-17: Updated the
bin/deploy.sh
script to download thebox.phar
securely, instead of using their installer script.