Blog / Sending S/MIME encrypted emails with Action Mailer

January 26, 2015

Why send encrypted emails?

Most people are aware now that sending emails is somewhat akin to sending a postcard - pretty much everyone in the delivery chain is able to have a peek at your message. The only way to prevent this is to encrypt your message so that it appears as gobbledygook to anyone who tries to have a look. In addition to encrypting your email you’ll also want to sign it as well to make sure that it hasn’t been tampered with in transit.

The de-facto standard for encrypting and signing emails is S/MIME, which has been around for well over a decade now and has wide support in email clients - the exception being the Google Mail web client but it does work fine when accessing Google Mail via IMAP with a client that supports S/MIME.

Support for S/MIME in Ruby comes as part of the OpenSSL standard library. It’s very lightly documented but fortunately there’s an example of signing and encrypting an email within the Ruby sample code.

Getting started

The technique we will be using to send our encrypted emails is a rarely used feature in Action Mailer called interceptors. This feature allows you to hook into the email delivery before the email is sent, but after the email has been generated. The definition is straightforward with a single singleton method called delivering_email, e.g:

class MessageInterceptor
  def self.delivering_email(message)
    message.to = ['test.user@ubxd.com']
  end
end

ActionMailer::Base.register_interceptor(MessageInterceptor)

There are some nifty tricks that can be performed using this feature, one of which is inlining CSS for HTML emails - see premailer-rails for more information on how to do this.

Implementing the delivering_email method

Because of the way interceptors are implemented we have to mutate the message that’s passed to our message, therefore we’ll create an encrypted version of the message and copy the body back onto the original message along with certain headers. The basic outline looks like this:

require 'openssl'

class MessageEncryptor
  class << self
    include OpenSSL

    def delivering_email(message)
      encrypted_message = sign_and_encrypt(message.encoded, message.to)
      overwrite_body(message, encrypted_message)
      overwrite_headers(message, encrypted_message)
    end

    private

    def sign_and_encrypt(data, recipients)
      encrypt(sign(data), certificates_for(recipients))
    end

  end
end

Obviously we need to require the OpenSSL library and since all of the methods we’ll be writing are singletons we can save writing self.method by using the class << self construct to define our methods on the singleton class. Some of the OpenSSL constants are quite deeply nested so we also include it in our singleton class to save some more writing.

We pass in message.encoded to our sign_and_encrypt method since the signing phase needs to work with the full message - headers and body.

Signing the message

The first step in the process is to sign the message. This is done using the PKCS7 module from OpenSSL and looks like this:

def sign(data)
  PKCS7.write_smime(PKCS7.sign(certificate, private_key, data, [], PKCS7::DETACHED))
end

def certificate
  @certificate ||= X509::Certificate.new(File.read(certificate_path))
end

def certificate_path
  Rails.root.join('config', 'server.pem')
end

def private_key
  @private_key ||= PKey::RSA.new(File.read(private_key_path))
end

def private_key_path
  Rails.root.join('config', 'server.key')
end

The certificate is needed because it’s sent as an attachment to the message so that the receiver will have a copy of it to verify the signature. The private_key is obviously used to generate the signature and the data argument is the full message with headers. The empty array is used to pass additional certificates - for example if you needed to send any additional intermediate certificates. The PKCS7::DETACHED constant just tells OpenSSL to use the form of S/MIME message where the certificate is sent as an attachement.

The sign method returns a PKCS7 object but that’s of no use to us so we use the write_smime method to convert it into a familiar format.

We’re just using a server-wide certificate and private key - however if you have a multi-tenant application it is straightforward to pull the date for these from an Active Record model.

Encrypting the message

Now that we’ve got our signed message the next thing to do is to encrypt it which looks like this:

def encrypt(data, certificates)
  Mail.new(PKCS7.write_smime(PKCS7.encrypt(certificates, data, cipher)))
end

def cipher
  @cipher ||= Cipher.new('AES-128-CBC')
end

def certificates_for(recipients)
  recipients.map do |recipient|
    X509::Certificate.new(File.read(certificate_path_for(recipient)))
  end
end

def certificate_path_for(recipient)
  Rails.root.join('config', 'certificates', "#{recipient}.pem")
end

The first thing we need is to create X.509 certificates for each of the recipients. Again they’re just being stored locally in the config directory but could quite easily be an attribute on a user model.

We pass the certificates along with our signed S/MIME data to the encrypt method which similar to the sign method returns a PKCS7 object which we convert to S/MIME data with the write_smime method. We then parse it using the mail gem (which is what powers much of Action Mailer) so that we can easily copy the body and required headers.

Just a small note about the cipher method - by default OpenSSL will using 40-bit RC2 for encrypting the message so it’s a good idea to boost it to something more powerful but you may have to adjust it back down to Triple-DES for better compatibility with older mail clients.

Overwriting the original message body and headers

Now that we have our signed and encrypted message the next thing we need to do is to replace the original body with the new body and replace some headers relating to content type, disposition and encoding like this:

def overwrite_body(message, encrypted_message)
  message.body = nil
  message.body = encrypted_message.body.encoded
end

def overwrite_headers(message, encrypted_message)
  message.content_disposition = encrypted_message.content_disposition
  message.content_transfer_encoding = encrypted_message.content_transfer_encoding
  message.content_type = encrypted_message.content_type
end

The message.body = nil is important so that the mail gem doesn’t add the new body on as an attachement instead of replacing the body.

Hooking in our interceptor

The last thing to do is to add our interceptor to the list of interceptors. This config option can be placed in application.rb, a environment file like production.rb or even a config/initializers file. My preference is to create an email.rb initializer and add the delivery method settings in there as well:

ActionMailer::Base.delivery_method = :smtp
ActionMailer::Base.smtp_settings = {
  # SMTP settings
}

ActionMailer::Base.register_interceptor(MessageEncryptor)

The order of the interceptors is important and we need to make sure that ours will be the last one otherwise the signature will be invalidated (not to mention the likelihood of other interceptors choking on the encrypted data).

Once it’s hooked in then it’s completely transparent - you call your email delivery methods in the same way as you did previously:

>> Notifications.send_invoice(order).deliver

Assuming that all the certificates are in place you should receive an email in short order that is both signed and encrypted. If you’re having problems it’s almost certainly a mismatch with the certificates - either the trust level of a self-signed certificate isn’t high enough or the common name doesn’t match the email address.

Here’s the complete class:

require 'openssl'

class MessageEncryptor
  class << self
    include OpenSSL

    def delivering_email(message)
      encrypted_message = sign_and_encrypt(message.encoded, message.to)
      overwrite_body(message, encrypted_message)
      overwrite_headers(message, encrypted_message)
    end

    private

    def sign_and_encrypt(data, recipients)
      encrypt(sign(data), certificates_for(recipients))
    end

    def sign(data)
      PKCS7.write_smime(PKCS7.sign(certificate, private_key, data, [], PKCS7::DETACHED))
    end

    def encrypt(data, certificates)
      Mail.new(PKCS7.write_smime(PKCS7.encrypt(certificates, data, cipher)))
    end

    def cipher
      @cipher ||= Cipher.new('AES-128-CBC')
    end

    def certificate
      @certificate ||= X509::Certificate.new(File.read(certificate_path))
    end

    def certificate_path
      Rails.root.join('config', 'server.pem')
    end

    def private_key
      @private_key ||= PKey::RSA.new(File.read(private_key_path))
    end

    def private_key_path
      Rails.root.join('config', 'server.key')
    end

    def certificates_for(recipients)
      recipients.map do |recipient|
        X509::Certificate.new(File.read(certificate_path_for(recipient)))
      end
    end

    def certificate_path_for(recipient)
      Rails.root.join('config', 'certificates', "#{recipient}.pem")
    end

    def overwrite_body(message, encrypted_message)
      message.body = nil
      message.body = encrypted_message.body.encoded
    end

    def overwrite_headers(message, encrypted_message)
      message.content_disposition = encrypted_message.content_disposition
      message.content_transfer_encoding = encrypted_message.content_transfer_encoding
      message.content_type = encrypted_message.content_type
    end
  end
end

The full class has been posted it to a Gist to make it easier for you to download and use within your application.

Getting certificates

If you know what you’re doing and are only interested in sending emails between yourselves then you can create your own CA (Certificate Authority) and issue your own S/MIME certificates but if you need to send your emails to other people outside of your company then you’ll need a S/MIME certificate from a recognised CA - I’ve used GlobalSign PersonalSign certificates myself and they are reasonable value for money at £20 per year for the basic certificate. There are other CAs available - the MozillaZine Knowledgebase has a list of free options.