Reading Rails - How Does MessageEncryptor Work?

Previously, we investigated the implementation of MessageVerifier. If you haven't read it yet, do so now, this article relies heavily on it.

As MessageVerifier's name implies, it lets you verify that a message has not been tampered with. Now we will look at how MessageEncryptor uses MessageVerifier and OpenSSL to encrypt data.

Encrypting Data

MessageEncryptor uses OpenSSL's Ciphers to perform symmetric encryption. This means that if you have the secret value the message was encrypted with you can also decrypt it. Let's see an example:

# Don't actually use the strings "secret" and "salt"
key = ActiveSupport::KeyGenerator.new('secret').generate_key("salt")
crypt = ActiveSupport::MessageEncryptor.new(key)

encrypted_data = crypt.encrypt_and_sign('message')
#=> "WUhyekN0dUI0YTNxVG1Fdis1YnFIUDJ1T2Ri...--160285fb22539d673a..." 
crypt.decrypt_and_verify(encrypted_data)
#=> "message"

The two methods we will be examining in detail here will be encrypt_and_sign and its counterpart, decrypt_and_verify.

How It Works

Open up lib/activesupport/message_encryptor.rb, and let's start reading. Hop on down to where ActiveSupport defines its exceptions. In past articles, we've seen how easy it is to create custom exception classes, but there's another interesting trick here:

OpenSSLCipherError = OpenSSL::Cipher::CipherError

OpenSSL::Cipher::CipherError is being assigned to OpenSSLCipherError. This provides a shortcut for referencing OpenSSL's exception later. It's a negligible savings, but it demonstrates how malleable Ruby is.

MessageEncryptor's initializer is a bit more complicated that you might expect from the documentation. The only documented options are secret, :cipher, and :serializer, but you can also pass in a custom sign_secret that is used for the MessageVerifier. Since it isn't documented, you probably don't want to rely on this, but as an exercise lets see how it gets set.

def initialize(secret, *signature_key_or_options)
  options = signature_key_or_options.extract_options!
  sign_secret = signature_key_or_options.first
  @secret = secret
  @sign_secret = sign_secret
  @cipher = options[:cipher] || 'aes-256-cbc'
  @verifier = MessageVerifier.new(@sign_secret || @secret, :serializer => NullSerializer)
  @serializer = options[:serializer] || Marshal
end

signature_key_or_options will be an array with zero or more values. As we've seen previously extract_options! will pop a hash of options off the end of an array if present. Next sign_secret gets the first value from signature_key_or_options. If the the array is empty, sign_secret will be nil. Let's see some examples:

ActiveSupport::MessageEncryptor.new("secret") 
# options == {}, sign_secret == nil
ActiveSupport::MessageEncryptor.new("secret", :cipher => "aes-256-cbc")
# options == {:cipher => "aes-256-cbc"}, sign_secret == nil
ActiveSupport::MessageEncryptor.new("secret", "secret_2")
# options == {}, sign_secret == "secret_2"
ActiveSupport::MessageEncryptor.new("secret", "secret_2", :cipher => "aes-256-cbc")
# options == {:cipher => "aes-256-cbc"}, sign_secret == secret_2

Take a look at how MessageVerifier is initialized with a custom serializer. What is this NullSerializer?

module NullSerializer
  def self.load(value)
    value
  end

  def self.dump(value)
    value
  end
end

We saw that MessageVerifier could take anything that responded to load and dump and use it as a serializer. Although this conforms to the interface MessageVerifier expects, it seems quite strange. To understand the point of this, take a look at encrypt_and_sign:

def encrypt_and_sign(value)
  verifier.generate(_encrypt(value))
end

Reading this inside out, we first encrypt the value, and then verify it. In order to encrypt it, MessageEncryptor will serialize the data so there's no point in MessageVerifier serializing it again. Although a bit confusing, this demonstrates the power of both duck typing, and the Strategy Pattern.

We've already seen MessageVerifier#generate, let's see how _encrypt is implemented:

def _encrypt(value)
  cipher = OpenSSL::Cipher::Cipher.new(@cipher)
  cipher.encrypt
  cipher.key = @secret

  # Rely on OpenSSL for the initialization vector
  iv = cipher.random_iv

  encrypted_data = cipher.update(@serializer.dump(value))
  encrypted_data << cipher.final

  "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}"
end

Ruby's OpenSSL library is a bit awkward to work with. After creating a Cipher, you need to configure it. For instance you need to either call encrypt or decrypt to set the Cipher's mode. This API closely mirrors the C implementation, but what may be idiomatic in one language does not always translate well to another. Sometimes when reading code, it's worth imagining how you might have written something:

# A more idiomatic API might have looked like this:
OpenSSL::Cipher.new(@ciper, :encrypt, :key => @secret)

Of course we have the luxury of not worrying about breaking an existing API. If you find yourself wrapping a foreign library, think about how you could make it play nicely with Ruby idioms.

The cipher also needs an initialization vector before you can use it. To the best of my knowledge an initialization vector is similar to a salt, but can be passed around with the encrypted text. Calling cipher.random_iv sets the cipher's initialization vector to random value, and returns it.

Once configured, we can actually use the cipher. Calling update will return chunks of encrypted text as they are available. Calling final will output the remainder:

message = ""
message << cipher.update("Message 1")
# ""
message << cipher.update("Message 2")
# "Q\xB1\xC5jy%=Zxh\x19\rMf*\xD4" 
message << cipher.final
# "Q\xB1\xC5jy%=Zxh\x19\rMf*\xD4\x1D\xCF,\x969^AR\xA6\xE8_\x03\xA6|\\\xE2"

Notice that it doesn't always return anything. This is because the encryption algorithm needs to buffer up data before it can encrypt it. Calling final forces it to emit whatever is left. Beware, OpenSSL does not check to make sure that you call final, and it also does not prevent you from calling update after final. In both these cases, it will simply return garbage.

At the very end of _encrypt the encrypted data is Base64 encoded along with the initialization vector using the same scheme we saw in MessageVerifier.

decrypt_and_verify undoes the effects of encrypt_and_sign as you might expect. First it verifies the data hasn't been tampered with using MessageVerifier, and then it calls _decrypt. The _decrypt method follows the same pattern as _encrypt, but more or less in reverse.

There you have it, symmetric encryption in Rails.

Recap

We have seen how Rails can sign and encrypt our data using MessageEncryptor. We also saw an example of how Rails uses the Strategy Pattern when serializing data.

You may never use OpenSSL directly, but if you do, you can learn from MessageEncryptor:

Luckily for us, MessageEncryptor takes care of these details.

If you want to dig deeper, look at how ActionDispatch::EncryptedCookieJar uses MessageEncryptor, or read up on how ActiveSupport::KeyGenerator allows you to use the same secret key in different contexts.

As always let me know if I missed anything, or if you have any questions.

blog comments powered by Disqus
Monkey Small Crow Small