A Practical Guide to Using Signed Ruby Gems - Part 2: Heroku

In Part 1 we looked at how to use the newest bundler release to apply a "trust policy" to the gems upon which your Ruby app depends. Trust policies force rubygems to check the signatures on each of your gems, and refuse to install if those signatures are missing or invalid.

I showed you how to enable a trust policy when you install gems locally, but it's just as (if not more) important to verify the gems you install when deploying to staging or production. In this post, I'm introducing a Heroku buildpack that enforces a trust policy and checks gem signatures based on your configuration.

Getting Started

I know you're impatient, so first I'll show you how to use it, and then I'll explain how it works. To get started, you'll need to tell your Heroku app to use a custom buildpack:

# New Heroku app
$ heroku create --buildpack https://github.com/bradleybuda/heroku-buildpack-ruby.git

# Existing app
$ heroku config:set BUILDPACK_URL=https://github.com/bradleybuda/heroku-buildpack-ruby.git

# Run this for both new and existing apps.
# More on why later in the post.
$ heroku labs:enable user-env-compile

The default policy is MediumSecurity, which checks for valid signatures on any gems that are signed, but permits unsigned gems. Even MediumSecurity is a weak policy, since an attacker doesn't really need to forge a signature to push a bad gem - he just needs to strip the signature completely. However, since there just aren't many signed gems out there yet, it's very unlikely that you'll be able to put together a non-trivial app that uses HighSecurity. If you do decide to opt for a different policy, you can set it in your app config:

$ heroku config:set GEM_TRUST_POLICY=HighSecurity

The settings you've put in place don't take effect until your first deployment. Let's deploy our code with the new buildpack and make sure everything works:

$ git push heroku   # Or whatever your heroku remote is called
-----> Fetching custom git buildpack... done
-----> Ruby/Rack app detected
-----> Installing certificates from certs/*.pem
-----> Installing dependencies using Bundler version 1.3.0
       Running: bundle install --without development:test --path vendor/bundle --binstubs vendor/bundle/bin --trust-policy MediumSecurity --deployment
       ...
       Your bundle is complete! It was installed into ./vendor/bundle
       Cleaning up the bundler cache.
       Would have removed bundler (1.3.0.pre.5)
-----> Discovering process types
       Procfile declares types     -> (none)
       Default types for Ruby/Rack -> console, rake, web

-----> Compiled slug size: 588K
-----> Launching... done, v10
       http://ancient-spire-7732.herokuapp.com deployed to Heroku

Unless you changed your Gemfile.lock, it's likely that nothing happened, other than a couple of new log messages from Heroku. What you're seeing is trust policy bootstrapping problem that we encountered in part 1 - Bundler only checks gem signatures on first install, so any gems that are cached in your slug will remain there, unverified.

To handle this case, the buildpack accepts another flag that forces the reinstallation of all your gems, which will cause their signatures to be verified (don't forget to config:unset this flag when you're done):

$ heroku config:set PURGE_BUNDLER_CACHE=true
$ git push

Depending on what you have in your Gemfile.lock, you probably saw an error in this push, telling you that a gem's signature could not be verified. The MediumSecurity policy will attempt to verify the code signatures of any gems that are signed, and will fail if it cannot find a public key matching those signatures. Unlike your web browser, rubygems does not come with any trusted certificates out of the box - you must tell it which certificates to trust. Our buildpack does this by using the certs/ directory in your app's home. Let's add a trusted certificate for a gem we want to verify. In my sample app, I'm using the signed multi_json gem. I'll add the gem author's certificate to my codebase and try deploying again:

$ mkdir certs
$ curl https://gist.github.com/sferik/4701180/raw/public_cert.pem > certs/sferik-gmail-com.pem
$ git add certs
$ git commit -a -m "Trust sferik-gmail-com"
$ git push
-----> Fetching custom git buildpack... done
-----> Ruby/Rack app detected
-----> Installing certificates from certs/*.pem
       Added '/CN=sferik/DC=gmail/DC=com'
-----> Installing dependencies using Bundler version 1.3.0
       Running: bundle install --without development:test --path vendor/bundle --binstubs vendor/bundle/bin --trust-policy MediumSecurity --deployment
       Fetching gem metadata from https://rubygems.org/..........
       Installing multi_json (1.6.1)
       Installing rack (1.4.5)
       Using bundler (1.3.0)
       Your bundle is complete! It was installed into ./vendor/bundle
       Cleaning up the bundler cache.
-----> Discovering process types
       Procfile declares types     -> (none)
       Default types for Ruby/Rack -> console, rake, web

-----> Compiled slug size: 440K
-----> Launching... done, v28
       http://ancient-spire-7732.herokuapp.com deployed to Heroku

And it works! As you add more dependencies to your Gemfile (or update existing versions) they will be verified at deploy time.

How it works, and some limitations

Patching the Heroku buildpack to make this work was actually relatively simple, due to the new --trust-policy flag to bundle. The buildpack uses Heroku's user-env-compile lab to bring two parameters in from the ENV - GEM_TRUST_POLICY and PURGE_BUNDLER_CACHE. The value of GEM_TRUST_POLICY is passed right through to bundle install, and PURGE_BUNDLER_CACHE is an extra flag to the load_bundler_cache method already in the buildpack.

However, there are some significant limitations with this approach. The signature of bundler itself is not checked on install. There are a couple of reasons why it's not checked: first, the bundler gem doesn't have a signature to verify in the first place. And even if it did, it is installed as a "vendored gem", meaning it is simply dropped in to place from a tarball, and it does not go through the gem install command. Because bundler is needed to bootstrap your app (along with ruby and rubygems) it is pulled directly from Heroku's S3 bucket and is not subject to verification. I suppose you trust Heroku already if you've read this far.

The bigger problem (and the topic for the next part of this series) is the utter lack of signed ruby gems, and the problem of discovering public keys for gem authors. I'll take a look at the raw data on gem signing, and also go over an alternative proposal for signed gems in Part 3.

What else would you like to learn about gem signing? Discuss it with me on Hacker News.