Guide

Contents

This guide builds up from encrypting a single string to encrypting model attributes, configuration file passwords, and whole files. Each step assumes the ones before it, so you can read it top to bottom, or jump to the part you need.

Every example is runnable. Steps 1 and 2 need nothing but the gem installed.

Step 1: Encrypt your first value

You do not need a configuration file to try the library. Set a cipher by hand with a throwaway key:

require "symmetric_encryption"

SymmetricEncryption.cipher = SymmetricEncryption::Cipher.new(
  key:         "1234567890ABCDEF",
  iv:          "1234567890ABCDEF",
  cipher_name: "aes-128-cbc"
)

Now encrypt and decrypt:

encrypted = SymmetricEncryption.encrypt("Hello World")
# => "QEVuQwAANIuPIXv/ii1IP1dF6T0NpQ=="

SymmetricEncryption.decrypt(encrypted)
# => "Hello World"

The result is base64 text, safe to put in a database column, a YAML file, or an HTTP header.

You can ask whether a value came from this library. It reads the header rather than attempting to decrypt, so it is cheap and does not need the right key:

SymmetricEncryption.encrypted?(encrypted)
# => true

SymmetricEncryption.encrypted?("Hello World")
# => false

nil and "" pass through both methods untouched, so you never have to guard against them:

SymmetricEncryption.encrypt(nil)
# => nil
SymmetricEncryption.encrypt("")
# => ""

That key is published here, so it protects nothing. Step 2 replaces it.

Step 2: Generate real keys

Generate a configuration file and a key for every environment:

symmetric-encryption --generate --app-name my_app
New configuration file created at: config/symmetric-encryption.yml

Two kinds of file come out of this, and keeping them apart is the entire point:

In Rails, that is all the setup there is. The railtie loads the configuration during boot, early enough that database.yml can itself contain an encrypted password.

Outside Rails, load it yourself:

SymmetricEncryption.load!("config/symmetric-encryption.yml", "production")

See Configuration for the keystores, including AWS KMS and Google Cloud KMS.

Step 3: Encrypt a model attribute

Add a string column. It holds base64 text, so it is a string whatever the attribute’s type:

class AddSsnToPeople < ActiveRecord::Migration[8.0]
  def change
    add_column :people, :ssn, :string
  end
end

Declare the attribute with the :encrypted type:

class Person < ActiveRecord::Base
  attribute :ssn, :encrypted
end

It now behaves like any other attribute:

person = Person.create!(name: "Jack", ssn: "123456789")
person.ssn
# => "123456789"

person.reload.ssn
# => "123456789"

The database holds only ciphertext:

SELECT name, ssn FROM people;
-- Jack | QEVuQwJAEACOYREfF1cAXU0B8Xre7bISBCV415agBWeiX6cF1boT2g==

Using Mongoid instead? See Mongoid, which uses a field ... encrypted: true option rather than an attribute type.

Step 4: Choose the type

An encrypted attribute is stored as text, but it does not have to be text. Declare a type and the value is cast on the way in and on the way out:

class Person < ActiveRecord::Base
  attribute :ssn,           :encrypted
  attribute :age,           :encrypted, type: :integer
  attribute :date_of_birth, :encrypted, type: :date
end
person = Person.create!(ssn: "123456789", age: 21, date_of_birth: "1998-03-04")

person.age
# => 21               (Integer, not "21")
person.date_of_birth
# => Wed, 04 Mar 1998 (Date)

The available types are:

Type Ruby class
:string (default) String
:integer Integer
:float Float
:decimal BigDecimal
:datetime DateTime
:time Time
:date Date
:boolean TrueClass or FalseClass
:json Serialized with JSON, for hashes and arrays
:yaml Serialized with YAML, for hashes and arrays

Casting deliberately matches Active Record exactly, so an encrypted attribute is indistinguishable from an unencrypted one apart from the encryption. That includes the parts that surprise people:

Person.new(age: "abc").age
# => 0     Exactly what ActiveModel::Type::Integer does. It does not raise, and it is not nil.

Person.new(age: "").age
# => nil   A blank string is nil for every type except :string.

Casting never raises. To reject input that could not be cast, validate against *_before_type_cast, which is how you would do it for an unencrypted attribute.

Step 5: Decide whether the value must be searchable

By default each write produces a different ciphertext for the same input, because a new random initialization vector is generated every time. That is what you want: it stops an attacker learning that two rows hold the same value.

a = Person.create!(ssn: "123456789")
b = Person.create!(ssn: "123456789")
# The two rows hold completely different ciphertext.

The cost is that you cannot look the value up, because you cannot reproduce the ciphertext to search for:

Person.where(ssn: "123456789")   # Finds nothing. Never will.

When a column has to be searchable, turn the random IV off. The same input then always encrypts to the same output, and equality lookups work:

class Person < ActiveRecord::Base
  attribute :ssn, :encrypted, random_iv: false
end

Person.where(ssn: "123456789").first   # Works.

Understand the trade: anyone who can read the table can now see which rows share a value, even without the key. Use random_iv: false only on columns you genuinely query, and prefer an authenticated cipher when you do. See Security.

Only equality works. LIKE, ranges, and ordering operate on ciphertext and are meaningless.

Step 6: Keep decrypted values out of logs and JSON

Encrypting the column does nothing if the decrypted value is written to the log a moment later.

Logs and inspect are handled for you. Every attribute declared :encrypted is added to the model’s filter_attributes when it is declared:

person.inspect
# => #<Person id: 1, name: "Jack", ssn: [FILTERED], age: [FILTERED]>

Person.filter_attributes
# => [:ssn, :age]

This also adds "person.ssn" to the application’s config.filter_parameters, so the value is filtered out of request parameters in the logs too.

JSON is not, and that is deliberate. as_json still returns decrypted values, because an encrypted attribute that is deliberately rendered into an API response has to keep working:

person.as_json
# => {"id" => 1, "name" => "Jack", "ssn" => "123456789"}

To exclude them, include the module:

class Person < ActiveRecord::Base
  include SymmetricEncryption::ActiveRecord::ExcludeFromJson

  attribute :ssn, :encrypted
end
person.as_json
# => {"id" => 1, "name" => "Jack"}

Rendering one then takes asking for it by name, which is hard to do by accident:

person.as_json(methods: :ssn)
# => {"id" => 1, "name" => "Jack", "ssn" => "123456789"}

See Rails for both in detail.

Step 7: Encrypt a password in a configuration file

Encrypt the value from the command line:

symmetric-encryption --encrypt --prompt

You are prompted twice, and the encrypted value is printed. Paste it into the configuration file:

production:
  adapter:  postgresql
  database: my_app_production
  username: my_app
  password: <%= SymmetricEncryption.try_decrypt("QEVuQwJAEACOYREfF1cAXU0B8Xre7bISBCV415agBWeiX6cF1boT2g==") %>

try_decrypt rather than decrypt: it returns nil instead of raising when the value cannot be decrypted, so a developer machine without the production key can still load the file.

This works in database.yml because the railtie loads the configuration in before_configuration, deliberately earlier than Active Record.

When you rotate keys, re-encrypt every value already sitting in your configuration files:

symmetric-encryption --re-encrypt "**/*.yml"

Step 8: Encrypt a file

Writer and Reader behave like Ruby’s IO, encrypting and decrypting as data flows through them, so file size is not a constraint:

SymmetricEncryption::Writer.open("secure.enc") do |file|
  file.write("Hello World\n")
  file.write("Second line\n")
end
SymmetricEncryption::Reader.open("secure.enc") do |file|
  file.each_line { |line| puts line }
end
# Hello World
# Second line

Or encrypt a whole file in one call:

SymmetricEncryption::Writer.encrypt(source: "plain.txt", target: "secure.enc")

Each file gets its own random key, encrypted with your configured key and stored in the file’s header, so the file is self-describing. Compression is on by default for file names that are not already compressed.

See Files for streams, CSV, seeking, and tamper detection.

Going further

Detect tampering. aes-256-cbc will decrypt a modified value and return garbage. aes-256-gcm refuses. See Security.

Rotate keys without downtime, and read data encrypted by keys you retired years ago. See Key Rotation.

Use a different key for some of your data, for example a key per customer held in your database rather than in symmetric-encryption.yml:

SymmetricEncryption.with_cipher(cipher_for(customer)) do
  person.save!
end

See Key Rotation.

Encrypt one attribute with a different key from everything else:

attribute :api_key, :encrypted, version: 3

Next steps