Permalink
Browse files

Add Encryption, Sample User Model

AES encryption in CTR mode is used, using random IVs so that the
encryptor never produces the same cipher text for the same value. The
IV used to encrypt the plaintext is returned prepended to the cipher text.
Erlang’s `crypto:strong_rand_bytes` is used to ensure a cryptographically random
IV is generated.

Because this encryption method doesn’t produce the same value for the
same plaintext, we cannot query encrypted fields through Ecto. If we
try, Ecto will run the search term through the encryptor, which will
produce a new value, and there will be no match in the database.

This can be worked around by adding a second field for those fields we
want to be able to query: `field_name_hash`. This field contains a
sha256 hash of the contents of the `field_name` field. Since sha256
always produces the same value for the same plaintext, we can query on
this field. Therefore, to allow querying, a second `HashField` type is
implemented to transparently hash the search terms when you search on a
given field.

To make this work, it is also necessary to ensure that every time a row
is written to the database, the contents of `field_name` are copied to
`field_name_hash`. See `Encryption.User.set_hashed_fields/1` below, and
the Ecto model callbacks which ensure it is called.

**Security Implications of SHA256 hash fields**

The data cannot be reconstructed from the sha256 hash, though it does
reveal which rows have the same value in that field, since their sha256
hashes will match. Depending on your threat model, this may make this
method of querying the data unusable.
  • Loading branch information...
danielberkompas committed Jul 6, 2015
1 parent ce73d53 commit 80c9b75a39f89a203f80617e2c00c062e9904217
View
@@ -31,3 +31,6 @@ config :encryption, Encryption.Repo,
adapter: Ecto.Adapters.Postgres,
database: "encryption_dev",
size: 10 # The amount of database connections in the pool
config :encryption, Encryption.AES,
key: :base64.decode("vA/K/7K6Z3obnTxlPx6fDuy/tiPj4FS7dDtUpfvRbG4=")
View
@@ -15,3 +15,6 @@ config :encryption, Encryption.Repo,
database: "encryption_test",
pool: Ecto.Adapters.SQL.Sandbox, # Use a sandbox for transactional testing
size: 1
config :encryption, Encryption.AES,
key: :base64.decode("vA/K/7K6Z3obnTxlPx6fDuy/tiPj4FS7dDtUpfvRbG4=")
View
@@ -0,0 +1,60 @@
defmodule Encryption.AES do
@moduledoc """
Encrypt values with AES in CTR mode, using random IVs for each encryption.
See `encrypt/1` and `decrypt/1` for more details.
"""
@doc """
Encrypt a value. Uses a random IV for each call, and prepends the IV to the
ciphertext. This means that `encrypt/1` will never return the same ciphertext
for the same value.
## Parameters
- `plaintext`: Any type. Will be converted to a string using `to_string`
before encryption.
## Examples
iex> Encryption.AES.encrypt("test") != Encryption.AES.encrypt("test")
true
iex> ciphertext = Encryption.AES.encrypt(123)
...> is_binary(ciphertext)
true
"""
@spec encrypt(any) :: String.t
def encrypt(plaintext) do
iv = :crypto.strong_rand_bytes(16)
state = :crypto.stream_init(:aes_ctr, key, iv)
{_state, ciphertext} = :crypto.stream_encrypt(state, to_string(plaintext))
iv <> ciphertext
end
@doc """
Decrypt a binary.
## Parameters
- `ciphertext`: a binary to decrypt, assuming that the first 16 bytes of the
binary are the IV to use for decryption.
## Example
iex> Encryption.AES.encrypt("test") |> Encryption.AES.decrypt
"test"
"""
@spec decrypt(String.t) :: String.t
def decrypt(ciphertext) do
<<iv::binary-16, ciphertext::binary>> = ciphertext
state = :crypto.stream_init(:aes_ctr, key, iv)
{_state, plaintext} = :crypto.stream_decrypt(state, ciphertext)
plaintext
end
defp key do
Application.get_env(:encryption, Encryption.AES)[:key]
end
end
@@ -0,0 +1,20 @@
defmodule Encryption.EncryptedField do
alias Encryption.AES
@behaviour Ecto.Type
def type, do: :binary
def cast(value) do
{:ok, to_string(value)}
end
def dump(value) do
ciphertext = value |> to_string |> AES.encrypt
{:ok, ciphertext}
end
def load(value) do
{:ok, AES.decrypt(value)}
end
end
@@ -0,0 +1,21 @@
defmodule Encryption.HashField do
@behaviour Ecto.Type
def type, do: :binary
def cast(value) do
{:ok, to_string(value)}
end
def dump(value) do
{:ok, hash(value)}
end
def load(value) do
{:ok, value}
end
def hash(value) do
:crypto.hash(:sha256, value)
end
end
@@ -0,0 +1,14 @@
defmodule Encryption.Repo.Migrations.CreateUser do
use Ecto.Migration
def change do
create table(:users) do
add :name, :binary
add :email, :binary
add :email_hash, :binary
timestamps
end
end
end
View
@@ -0,0 +1,26 @@
defmodule Encryption.AESTest do
use ExUnit.Case
alias Encryption.AES
doctest Encryption.AES
test ".encrypt can encrypt a value" do
assert AES.encrypt("hello") != "hello"
end
test ".encrypt includes the random IV in the value" do
<<iv::binary-16, ciphertext::binary>> = AES.encrypt("hello")
assert String.length(iv) != 0
assert String.length(ciphertext) != 0
end
test ".encrypt does not produce the same ciphertext twice" do
assert AES.encrypt("hello") != AES.encrypt("hello")
end
test "can decrypt a value" do
plaintext = "hello" |> AES.encrypt |> AES.decrypt
assert plaintext == "hello"
end
end
@@ -0,0 +1,24 @@
defmodule Encryption.EncryptedFieldTest do
use ExUnit.Case
alias Encryption.EncryptedField, as: Type
test ".type is :binary" do
assert Type.type == :binary
end
test ".cast converts a value to a string" do
assert {:ok, "123"} == Type.cast(123)
end
test ".dump encrypts a value" do
{:ok, ciphertext} = Type.dump("hello")
assert ciphertext != "hello"
assert String.length(ciphertext) != 0
end
test ".load decrypts a value" do
{:ok, ciphertext} = Type.dump("hello")
assert {:ok, "hello"} == Type.load(ciphertext)
end
end
@@ -0,0 +1,23 @@
defmodule Encryption.HashFieldTest do
use ExUnit.Case
alias Encryption.HashField, as: Type
test ".type is :binary" do
assert Type.type == :binary
end
test ".cast converts a value to a string" do
assert {:ok, "123"} == Type.cast(123)
end
test ".dump converts a value to a sha256 hash" do
{:ok, hash} = Type.dump("hello")
assert hash == <<44, 242, 77, 186, 95, 176, 163, 14, 38, 232, 59, 42, 197, 185, 226, 158, 27, 22, 30, 92, 31, 167, 66, 94, 115, 4, 51, 98, 147, 139, 152, 36>>
end
test ".load does not modify the hash, since the value cannot be reconstructed" do
hash = <<44, 242, 77, 186, 95, 176, 163, 14, 38, 232, 59, 42, 197, 185, 226, 158, 27, 22, 30, 92, 31, 167, 66, 94, 115, 4, 51, 98, 147, 139, 152, 36>>
assert {:ok, ^hash} = Type.load(hash)
end
end
View
@@ -0,0 +1,49 @@
defmodule Encryption.UserTest do
use Encryption.ModelCase
alias Encryption.User
@valid_attrs %{name: "Daniel", email: "test@example.com"}
@invalid_attrs %{}
test "changeset with valid attributes" do
changeset = User.changeset(%User{}, @valid_attrs)
assert changeset.valid?
end
test "changeset with invalid attributes" do
changeset = User.changeset(%User{}, @invalid_attrs)
refute changeset.valid?
end
test "changeset validates uniqueness of email through email_hash" do
Repo.insert! User.changeset(%User{}, @valid_attrs)
assert {:email_hash, "has already been taken"} in errors_on(%User{}, %{email: @valid_attrs.email})
end
test "can decrypt values of encrypted fields when loaded from database" do
Repo.insert! User.changeset(%User{}, @valid_attrs)
user = Repo.one(User)
assert user.name == @valid_attrs.name
assert user.email == @valid_attrs.email
assert user.email_hash == Encryption.HashField.hash(@valid_attrs.email)
end
test "inserting a user updates the :email_hash field" do
user = Repo.insert! User.changeset(%User{}, @valid_attrs)
assert user.email_hash == @valid_attrs.email
end
test "cannot query on email field due to encryption not producing same value twice" do
Repo.insert! User.changeset(%User{}, @valid_attrs)
assert Repo.get_by(User, email: @valid_attrs.email) == nil
end
test "can query on email_hash field because sha256 is deterministic" do
Repo.insert! User.changeset(%User{}, @valid_attrs)
assert %User{} = Repo.get_by(User, email_hash: @valid_attrs.email)
assert %User{} = Repo.one(from u in User, where: u.email_hash == ^@valid_attrs.email)
end
end
View
@@ -0,0 +1,36 @@
defmodule Encryption.User do
use Encryption.Web, :model
schema "users" do
field :name, Encryption.EncryptedField
field :email, Encryption.EncryptedField
field :email_hash, Encryption.HashField
timestamps
end
# Ensure that hashed fields never get out of date
before_insert :set_hashed_fields
before_update :set_hashed_fields
@required_fields ~w(name email)
@optional_fields ~w()
@doc """
Creates a changeset based on the `model` and `params`.
If no params are provided, an invalid changeset is returned
with no validation performed.
"""
def changeset(model, params \\ :empty) do
model
|> cast(params, @required_fields, @optional_fields)
|> set_hashed_fields
|> validate_unique(:email_hash, on: Encryption.Repo)
end
defp set_hashed_fields(changeset) do
changeset
|> put_change(:email_hash, changeset.changes[:email] || changeset.model.email)
end
end

2 comments on commit 80c9b75

@philss

This comment has been minimized.

Show comment
Hide comment
@philss

philss Aug 11, 2015

@danielberkompas Awesome! I didn't thought it was that easy to extend Ecto types. Great post and great example! 👍

@danielberkompas Awesome! I didn't thought it was that easy to extend Ecto types. Great post and great example! 👍

@nelsonic

This comment has been minimized.

Show comment
Hide comment
@nelsonic

nelsonic Jan 25, 2018

If anyone is interested, I've updated the code for Phoenix 1.3 (latest)
https://github.com/nelsonic/phoenix-ecto-encryption-example

If anyone is interested, I've updated the code for Phoenix 1.3 (latest)
https://github.com/nelsonic/phoenix-ecto-encryption-example

Please sign in to comment.