Securely search encrypted database fields
Works with Lockbox (full example) and attr_encrypted (full example)
Learn more about securing sensitive data in Rails
We use this approach by Scott Arciszewski. To summarize, we compute a keyed hash of the sensitive data and store it in a column. To query, we apply the keyed hash function to the value we’re searching and then perform a database search. This results in performant queries for exact matches. Efficient LIKE
queries are not possible, but you can index expressions.
An important consideration in searchable encryption is leakage, which is information an attacker can gain. Blind indexing leaks that rows have the same value. If you use this for a field like last name, an attacker can use frequency analysis to predict the values. In an active attack where an attacker can control the input values, they can learn which other values in the database match.
Here’s a great article on leakage in searchable encryption. Blind indexing has the same leakage as deterministic encryption.
Add this line to your application’s Gemfile:
gem "blind_index"
Your model should already be set up with Lockbox or attr_encrypted. The examples are for a User
model with has_encrypted :email
or attr_encrypted :email
. See the full examples for Lockbox and attr_encrypted if needed.
Also, if you use attr_encrypted, generate a key.
Create a migration to add a column for the blind index
add_column :users, :email_bidx, :string
add_index :users, :email_bidx # unique: true if needed
Add to your model
class User < ApplicationRecord
blind_index :email
end
For more sensitive fields, use
class User < ApplicationRecord
blind_index :email, slow: true
end
Backfill existing records
BlindIndex.backfill(User)
And query away
User.where(email: "[email protected]")
You can apply expressions to attributes before indexing and searching. This gives you the the ability to perform case-insensitive searches and more.
class User < ApplicationRecord
blind_index :email, expression: ->(v) { v.downcase }
end
You can use blind indexes for uniqueness validations.
class User < ApplicationRecord
validates :email, uniqueness: true
end
We recommend adding a unique index to the blind index column through a database migration.
add_index :users, :email_bidx, unique: true
For allow_blank: true
, use:
class User < ApplicationRecord
blind_index :email, expression: ->(v) { v.presence }
validates :email, uniqueness: {allow_blank: true}
end
For case_sensitive: false
, use:
class User < ApplicationRecord
blind_index :email, expression: ->(v) { v.downcase }
validates :email, uniqueness: true # for best performance, leave out {case_sensitive: false}
end
You may want multiple blind indexes for an attribute. To do this, add another column:
add_column :users, :email_ci_bidx, :string
add_index :users, :email_ci_bidx
Update your model
class User < ApplicationRecord
blind_index :email
blind_index :email_ci, attribute: :email, expression: ->(v) { v.downcase }
end
Backfill existing records
BlindIndex.backfill(User, columns: [:email_ci_bidx])
And query away
User.where(email_ci: "[email protected]")
If you don’t need to store the original value (for instance, when just checking duplicates), use a virtual attribute:
class User < ApplicationRecord
attribute :email, :string
blind_index :email
end
You can also use virtual attributes to index data from multiple columns:
class User < ApplicationRecord
attribute :initials, :string
blind_index :initials
before_validation :set_initials, if: -> { changes.key?(:first_name) || changes.key?(:last_name) }
def set_initials
self.initials = "#{first_name[0]}#{last_name[0]}"
end
end
If you’re encrypting a column and adding a blind index at the same time, use the migrating
option.
class User < ApplicationRecord
blind_index :email, migrating: true
end
This allows you to backfill records while still querying the unencrypted field.
BlindIndex.backfill(User)
Once that completes, you can remove the migrating
option.
To rotate keys without downtime, add a new column:
add_column :users, :email_bidx_v2, :string
add_index :users, :email_bidx_v2
And add to your model
class User < ApplicationRecord
blind_index :email, rotate: {version: 2, master_key: ENV["BLIND_INDEX_MASTER_KEY_V2"]}
end
This will keep the new column synced going forward. Next, backfill the data:
BlindIndex.backfill(User, columns: [:email_bidx_v2])
Then update your model
class User < ApplicationRecord
blind_index :email, version: 2, master_key: ENV["BLIND_INDEX_MASTER_KEY_V2"]
end
Finally, drop the old column.
The master key is used to generate unique keys for each blind index. This technique comes from CipherSweet. The table name and blind index column name are both used in this process.
You can get an individual key with:
BlindIndex.index_key(table: "users", bidx_attribute: "email_bidx")
To rename a table with blind indexes, use:
class User < ApplicationRecord
blind_index :email, key_table: "original_table"
end
To rename a blind index column, use:
class User < ApplicationRecord
blind_index :email, key_attribute: "original_column"
end
Argon2id is used for best security. The default cost parameters are 3 iterations and 4 MB of memory. For slow: true
, the cost parameters are 4 iterations and 32 MB of memory.
A number of other algorithms are also supported. Unless you have specific reasons to use them, go with Argon2id.
You can use blind indexes in fixtures with:
test_user:
email_bidx: <%= User.generate_email_bidx("[email protected]").inspect %>
Be sure to include the inspect
at the end or it won’t be encoded properly in YAML.
For Mongoid, use:
class User
field :email_bidx, type: String
index({email_bidx: 1})
end
This is optional for Lockbox, as its master key is used by default.
Generate a key with:
BlindIndex.generate_key
Store the key with your other secrets. This is typically Rails credentials or an environment variable (dotenv is great for this). Be sure to use different keys in development and production. Keys don’t need to be hex-encoded, but it’s often easier to store them this way.
Set the following environment variable with your key (you can use this one in development)
BLIND_INDEX_MASTER_KEY=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
or create config/initializers/blind_index.rb
with something like
BlindIndex.master_key = Rails.application.credentials.blind_index_master_key
Unfortunately, blind indexes can’t be used for LIKE
, ILIKE
, or full-text searching. Instead, records must be loaded, decrypted, and searched in memory.
For LIKE
, use:
User.select { |u| u.email.include?("value") }
For ILIKE
, use:
User.select { |u| u.email =~ /value/i }
For full-text or fuzzy searching, use a gem like FuzzyMatch:
FuzzyMatch.new(User.all, read: :email).find("value")
If the number of records is large, try to find a way to narrow it down. An expression index is one way to do this, but leaks which records have the same value of the expression, so use it carefully.
Set default options in an initializer with:
BlindIndex.default_options = {algorithm: :pbkdf2_sha256}
By default, blind indexes are encoded in Base64. Set a different encoding with:
class User < ApplicationRecord
blind_index :email, encode: ->(v) { [v].pack("H*") }
end
By default, blind indexes are 32 bytes. Set a smaller size with:
class User < ApplicationRecord
blind_index :email, size: 16
end
Set a key directly for an index with:
class User < ApplicationRecord
blind_index :email, key: ENV["USER_EMAIL_BLIND_INDEX_KEY"]
end
You can generate blind indexes from other languages as well. For Python, you can use argon2-cffi.
from argon2.low_level import Type, hash_secret_raw
from base64 import b64encode
key = '289737bab72fa97b1f4b081cef00d7b7d75034bcf3183c363feaf3e6441777bc'
value = '[email protected]'
bidx = b64encode(hash_secret_raw(
secret=value.encode(),
salt=bytes.fromhex(key),
time_cost=3,
memory_cost=2**12,
parallelism=1,
hash_len=32,
type=Type.ID
))
One alternative to blind indexing is to use a deterministic encryption scheme, like AES-SIV. In this approach, the encrypted data will be the same for matches. We recommend blind indexing over deterministic encryption because:
- You can keep encryption consistent for all fields (both searchable and non-searchable)
- Blind indexing supports expressions
View the changelog
Everyone is encouraged to help improve this project. Here are a few ways you can help:
- Report bugs
- Fix bugs and submit pull requests
- Write, clarify, or fix documentation
- Suggest or add new features
To get started with development and testing:
git clone https://github.com/ankane/blind_index.git
cd blind_index
bundle install
bundle exec rake test
For security issues, send an email to the address on this page.