Active Storage S3 Client-Side Encryption

Use client-side encryption to encrypt your data before sending it to S3.

You can provide an encryption key to use directly or a KMS key for envelope encryption. With envelope encryption, a data encryption key is retrieved from KMS and used to encrypt the file. An encrypted version of the key is stored in the object metadata. When downloading the file, the encrypted key is sent to KMS to be decrypted and then used to decrypt the file. The AWS SDK handles all this automatically.

An advantage of this approach is S3 never sees the unencrypted file (unlike with server-side encryption).

First, we’ll create a service that extends the built-in S3 service. Create lib/active_storage/services/encrypted_s3_service.rb with:

require "active_storage/service/s3_service"

module ActiveStorage
  class Service::EncryptedS3Service < Service::S3Service
    attr_reader :encryption_client

    def initialize(bucket:, upload: {}, **options)
      super_options = options.except(:kms_key_id, :encryption_key)
      super(bucket: bucket, upload: upload, **super_options)
      @encryption_client = Aws::S3::Encryption::Client.new(options)
    end

    def upload(key, io, checksum: nil, **)
      instrument :upload, key: key, checksum: checksum do
        begin
          encryption_client.put_object(
            upload_options.merge(
              body: io,
              content_md5: checksum,
              bucket: bucket.name,
              key: key
            )
          )
        rescue Aws::S3::Errors::BadDigest
          raise ActiveStorage::IntegrityError
        end
      end
    end

    def download(key, &block)
      if block_given?
        raise NotImplementedError, "#get_object with :range not supported yet"
      else
        instrument :download, key: key do
          encryption_client.get_object(
            bucket: bucket.name,
            key: key
          ).body.string.force_encoding(Encoding::BINARY)
        end
      end
    end

    def download_chunk(key, range)
      raise NotImplementedError, "#get_object with :range not supported yet"
    end
  end
end

Note that downloading chunks isn’t supported with client-side encryption.

Then update config/storage.yml to use it:

amazon:
  service: EncryptedS3
  bucket: my-bucket
  kms_key_id: alias/my-key

Use encryption_key instead of kms_key_id to manage keys yourself.

And that’s it!

For client-side encryption without Active Storage, check out this post.

Published November 12, 2018


You might also enjoy

Hybrid Cryptography on Rails

Client-Side Encryption with AWS and Ruby

Google OAuth with Devise


All code examples are public domain.
Use them however you’d like (licensed under CC0).