Enter the TamperProofCookieJar

Here’s the code (if it seems familiar it’s because it borrows heavily from the Rails cookie session store):

require 'openssl'

module ActionController
  class TamperProofCookieJar < ActionController::CookieJar

    class TamperedWithCookie < StandardError;
      attr_reader :cookie_name
      def initialize(cookie_name)
        @cookie_name = cookie_name.to_s
      end
    end

    def initialize(controller, secret, digest = 'SHA1')
      super(controller)
      @secret = secret
      @digest = OpenSSL::Digest::Digest.new(digest)
    end

    def [](name, protect_from_forgery = false)
      if (value = super(name)) && protect_from_forgery
        value, hmac = value.to_s.split('--')
        unless hmac == generate_digest(value) || hmac == generate_digest(value = CGI.unescape(value))
          raise TamperedWithCookie.new(name)
        end
      end

      value
    end

    def []=(name, options)
      if options.is_a?(Hash)
        options = options.stringify_keys
        options['name'] = name.to_s
        options['value'] = options['protect_from_forgery'] == true ? "#{options['value']}--#{generate_digest(options['value'])}" : options['value'].to_s
      else
        options = { "name" => name.to_s, "value" => options.to_s }
      end
      set_cookie(options)
    end

    protected

      def generate_digest(value)
        OpenSSL::HMAC.hexdigest(@digest, @secret, value.to_s)
      end

  end
end

The code makes use of the Ruby OpenSSL library to generate a HMAC using the SHA-1 hashing algorithm. A few notes about the code:

  • The TamperedWithCookie exception will be raised on reading a cookie value if it is deemed to have been tampered with. The exception provides a cookie_name attribute that can be used to get the name of the offending cookie.
  • The TamperProofCookieJar class is a subclass of the regular Rails CookieJar and as such is a drop-in replacement for it with one exception: the cookie value is converted to a string before being written to the cookie - this means if you’re not storing a simple string or number then you’ll need to ensure you have marshalled the data correctly before trying to write it to the cookie (again in most situations you shouldn’t be storing any complex objects in a cookie anyway).

Using the TamperProofCookieJar is easy:

# Write a regular cookie (by default HMAC protection is NOT used - so this is just like a regular Rails cookie)
cookies[:test_unprotected] = { :value => user.id, :expires => Time.zone.now + 30.days }

# Write a HMAC protected cookie
# user.id = 100
cookies[:test_protected] = { :value => user.id, :protect_from_forgery => true, :expires => Time.zone.now + 30.days }

# Read a regular cookie (again no protection is the default)
user_id = cookies[:test_unprotected]
=> "100"

# Read a HMAC protected cookie (the boolean parameter turns on HMAC protection)
# Raises TamperProofCookieJar::TamperedWithCookie if the cookie appears to have been changed
user_id = cookies[:test_protected, true]
=> "100"

# Read a raw HMAC protected cookie
# This returns the actual contents of the cookie, including the HMAC, and does not perform tamper checking
user_id = cookies[:test_protected]
=> "100--2157acbbe09b9f412cee3b97ad2e2c2136d3f5e1"

Replacing the Rails CookieJar

If you want to replace the Rails CookieJar with the TamperProofCookieJar in your application you can override the cookies method in your ApplicationController like so:

class ApplicationController < ActionController::Base
  def cookies
    ActionController::TamperProofCookieJar.new(self, 'this should be a long secret key')
  end
end

The second parameter should be a secret key that is hard to guess, just like the one used by the Rails cookie session store.

Performance improvements

As I said earlier, the TamperProofCookieJar was created as part of an optimisation project, so how does its performance compare with the original encryption solution? Using a simple benchmark on 20,000 encryptions versus 20,000 HMACs gives the following results:

Encryption total time
2.918 seconds
HMAC total time
0.854 seconds

This gives a 3.4x performance increase which on a high volume site such as the one I’m working on is a worthy saving!

To patch or plugin?

My original intention was to submit this as a patch to Rails however I don’t think it will be seen as “useful” enough for the wider Rails community to be included in core. Instead I’m leaning towards making it a plugin: so please let me know if you have any problems with the code or have any suggestions for how it can be improved and when time allows I’ll put it all together.