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:
- OpenSSL::Cipher needs to have its mode set
- You need to buffer the output of OpenSSL::Cipher, and call
final
- OpenSSL returns garbage if you call
update
afterfinal
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.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions