|
# TODO: Rework this to not rely on Hashicorp Vault. We can likely just store the data subject keys |
|
# in our own DB and encrypt them with our own master encryption key that will *not* be stored in |
|
# our own DB (possibly in AWS KMS?). At this point, we don't really need the other features of |
|
# Vault, so it's not really worth the additional work to get a Vault cluster set up. |
|
defprotocol TeamWebsite.PIIEncryption do |
|
@moduledoc """ |
|
Provides encryption for PII using Hashicorp's Vault |
|
|
|
[Vault](https://www.vaultproject.io) must be running and configured. The following system |
|
environment variables must be set in production: |
|
|
|
VAULT_ADDR="https://your.vault.host.example:8200" |
|
VAULT_TOKEN="token-for-app-role" |
|
|
|
In development and test environments, these default to `"http://vault:8200"` and `"root"`, |
|
respectively. Those values will work if you are using the included `docker-compose.yml` to |
|
launch the services for development and testing. |
|
""" |
|
|
|
@fallback_to_any true |
|
|
|
@type keypair() :: {data_subject_id :: binary(), encrypted_key :: binary()} |
|
@type key() :: |
|
{data_subject_id :: binary()} |
|
| keypair() |
|
| (unencrypted_key :: binary()) |
|
|
|
@spec encrypt(data :: any(), key :: key() | nil) :: |
|
{:ok, encrypted_data :: any()} |
|
| {:ok, encrypted_data :: any(), keypair() | nil} |
|
| {:error, reason :: atom()} |
|
@doc ~S""" |
|
Encrypts PII data for the given data subject |
|
|
|
If no `encryption_key` is provided, we use `ExCrypto` to generate a 256-bit AES encryption key, |
|
and that key is then used to encrypt the `pii_data`. The key itself is then encrypted using |
|
[Vault's transit engine](https://www.vaultproject.io/api/secret/transit/index.html). |
|
|
|
If an `encryption_key` *is* provided, then the key (which we assume to be the encrypted version |
|
of the key) will be decrypted via Vault, and the decrypted encryption key will be used to |
|
encrypt the `pii_data`. |
|
|
|
In either case, both the encrypted key and the encrypted PII are returned upon success. |
|
|
|
### Examples: ### |
|
|
|
Encrypting a single value, no key provided: |
|
iex> alias TeamWebsite.PIIEncryption, as: PII |
|
iex> data_subject_id = "68c57252-961a-43ef-85f4-e78fac327058" |
|
iex> pii_data = "John Doe" |
|
iex> {:ok, encrypted_data, key} = PII.encrypt(pii_data, {data_subject_id}) |
|
iex> encrypted_data == pii_data |
|
false |
|
iex> PII.decrypt(encrypted_data, key) |
|
{:ok, "John Doe"} |
|
|
|
Encrypting a map of data, no key provided: |
|
iex> alias TeamWebsite.PIIEncryption, as: PII |
|
iex> data_subject_id = "b65b2f14-22ab-4df1-91ea-0a05bcb88e44" |
|
iex> data = %{email: "[email protected]", name: "John Doe"} |
|
iex> {:ok, encrypted_data, key} = PII.encrypt(data, {data_subject_id}) |
|
iex> data.email == encrypted_data.email |
|
false |
|
iex> data.name == encrypted_data.name |
|
false |
|
iex> PII.decrypt(encrypted_data, key) |
|
{:ok, %{email: "[email protected]", name: "John Doe"}} |
|
|
|
Encrypting a single value, key provided: |
|
|
|
iex> alias TeamWebsite.PIIEncryption, as: PII |
|
iex> data_subject_id = "68c57252-961a-43ef-85f4-e78fac327058" |
|
iex> pii_data = "John Doe" |
|
iex> {:ok, _encrypted_data, key} = PII.encrypt("foo", {data_subject_id}) |
|
iex> {:ok, encrypted_data, _key} = PII.encrypt(pii_data, key) |
|
iex> encrypted_data == pii_data |
|
false |
|
iex> PII.decrypt(encrypted_data, key) |
|
{:ok, "John Doe"} |
|
|
|
Encrypting a map of data, key provided: |
|
iex> alias TeamWebsite.PIIEncryption, as: PII |
|
iex> data_subject_id = "b65b2f14-22ab-4df1-91ea-0a05bcb88e44" |
|
iex> data = %{email: "[email protected]", name: "John Doe"} |
|
iex> {:ok, _encrypted_data, key} = PII.encrypt("foo", {data_subject_id}) |
|
iex> {:ok, encrypted_data, _key} = PII.encrypt(data, key) |
|
iex> data.email == encrypted_data.email |
|
false |
|
iex> data.name == encrypted_data.name |
|
false |
|
iex> PII.decrypt(encrypted_data, key) |
|
{:ok, %{email: "[email protected]", name: "John Doe"}} |
|
""" |
|
def encrypt(data, key \\ nil) |
|
|
|
@spec decrypt(encrypted_data :: any(), key :: key() | nil) :: |
|
{:ok, data :: any()} | {:error, reason :: atom()} |
|
@doc """ |
|
Decrypts data using the provided encrypted key as returned by |
|
`TeamWebsite.PIIEncryption.encrypt/2`. |
|
""" |
|
def decrypt(encrypted_data, key \\ nil) |
|
end |
|
|
|
defmodule TeamWebsite.PIIEncryption.Keys do |
|
@moduledoc false |
|
|
|
alias TeamWebsite.PIIEncryption.Vault |
|
|
|
defmacro __using__(_) do |
|
quote do |
|
alias TeamWebsite.PIIEncryption |
|
alias TeamWebsite.PIIEncryption.Keys |
|
|
|
def encrypt(data, key) when is_tuple(key) do |
|
Keys.encrypt_for(key, data, fn data, key -> encrypt(data, key) end) |
|
end |
|
|
|
def decrypt(encrypted_data, key) when is_tuple(key) do |
|
Keys.decrypt_for(key, encrypted_data, fn data, key -> decrypt(data, key) end) |
|
end |
|
end |
|
end |
|
|
|
def encrypt_key(data_subject_id, key) do |
|
Vault.write("transit/keys/#{data_subject_id}") |
|
key = Base.encode64(key) |
|
|
|
{:ok, %{"ciphertext" => encrypted_key}} = |
|
Vault.write("transit/encrypt/#{data_subject_id}", %{"plaintext" => key}) |
|
|
|
{:ok, {data_subject_id, encrypted_key}} |
|
end |
|
|
|
def decrypt_key(data_subject_id, encrypted_key) do |
|
case Vault.write("transit/decrypt/#{data_subject_id}", %{"ciphertext" => encrypted_key}) do |
|
{:ok, %{"plaintext" => key}} -> {:ok, key |> Base.decode64!()} |
|
{:error, _} -> {:error, :decryption_failed} |
|
end |
|
end |
|
|
|
def encrypt_for({data_subject_id}, data, func) do |
|
{:ok, decrypted_key} = ExCrypto.generate_aes_key(:aes_256, :bytes) |
|
{:ok, encrypted_data} = func.(data, decrypted_key) |
|
{:ok, encrypted_key} = encrypt_key(data_subject_id, decrypted_key) |
|
{:ok, encrypted_data, encrypted_key} |
|
end |
|
|
|
def encrypt_for({data_subject_id, encrypted_key}, data, func) do |
|
{:ok, decrypted_key} = decrypt_key(data_subject_id, encrypted_key) |
|
{:ok, encrypted_data} = func.(data, decrypted_key) |
|
{:ok, encrypted_data, {data_subject_id, encrypted_key}} |
|
end |
|
|
|
def decrypt_for({data_subject_id, encrypted_key}, data, func) do |
|
{:ok, key} = decrypt_key(data_subject_id, encrypted_key) |
|
func.(data, key) |
|
end |
|
end |
|
|
|
defimpl TeamWebsite.PIIEncryption, for: BitString do |
|
use TeamWebsite.PIIEncryption.Keys |
|
|
|
def encrypt(data, key) when is_binary(key) do |
|
{:ok, {iv, encrypted_part}} = ExCrypto.encrypt(key, data) |
|
|
|
encrypted_data = |
|
[iv, encrypted_part] |
|
|> Enum.map(&Base.encode64/1) |
|
|> Enum.join(":") |
|
|
|
{:ok, encrypted_data} |
|
end |
|
|
|
def decrypt(encrypted_data, key) when is_binary(key) do |
|
[iv, encrypted_part] = |
|
encrypted_data |
|
|> String.split(":", parts: 2) |
|
|> Enum.map(&Base.decode64!/1) |
|
|
|
ExCrypto.decrypt(key, iv, encrypted_part) |
|
end |
|
end |
|
|
|
defimpl TeamWebsite.PIIEncryption, for: List do |
|
use TeamWebsite.PIIEncryption.Keys |
|
|
|
def encrypt(data, key) when is_binary(key) do |
|
encrypted_data = |
|
Enum.reduce(data, [], fn item, acc -> |
|
{:ok, encrypted_item} = PIIEncryption.encrypt(item, key) |
|
[encrypted_item | acc] |
|
end) |
|
|> Enum.reverse() |
|
|
|
{:ok, encrypted_data} |
|
end |
|
|
|
def decrypt(encrypted_data, key) when is_binary(key) do |
|
data = |
|
encrypted_data |
|
|> Enum.reverse() |
|
|> Enum.reduce([], fn item, acc -> |
|
{:ok, decrypted_item} = PIIEncryption.decrypt(item, key) |
|
[decrypted_item | acc] |
|
end) |
|
|
|
{:ok, data} |
|
end |
|
end |
|
|
|
defimpl TeamWebsite.PIIEncryption, for: Tuple do |
|
use TeamWebsite.PIIEncryption.Keys |
|
|
|
def encrypt({listkey, data}, key) when is_atom(listkey) and is_binary(key) do |
|
{:ok, encrypted_data} = PIIEncryption.encrypt(data, key) |
|
{:ok, {listkey, encrypted_data}} |
|
end |
|
|
|
def encrypt(data, key) when is_binary(key) do |
|
{:ok, encrypted_data} = |
|
data |
|
|> Tuple.to_list() |
|
|> PIIEncryption.encrypt(key) |
|
|
|
{:ok, List.to_tuple(encrypted_data)} |
|
end |
|
|
|
def decrypt({listkey, encrypted_data}, key) when is_atom(listkey) and is_binary(key) do |
|
{:ok, data} = PIIEncryption.decrypt(encrypted_data, key) |
|
{:ok, {listkey, data}} |
|
end |
|
|
|
def decrypt(encrypted_data, key) when is_binary(key) do |
|
{:ok, data} = |
|
encrypted_data |
|
|> Tuple.to_list() |
|
|> PIIEncryption.decrypt(key) |
|
|
|
{:ok, List.to_tuple(data)} |
|
end |
|
end |
|
|
|
defimpl TeamWebsite.PIIEncryption, for: Map do |
|
use TeamWebsite.PIIEncryption.Keys |
|
|
|
def encrypt(data, key) when is_binary(key) do |
|
{:ok, encrypted_data} = |
|
data |
|
|> Map.to_list() |
|
|> PIIEncryption.encrypt(key) |
|
|
|
{:ok, encrypted_data |> Enum.into(%{})} |
|
end |
|
|
|
def decrypt(encrypted_data, key) when is_binary(key) do |
|
{:ok, data} = |
|
encrypted_data |
|
|> Map.to_list() |
|
|> PIIEncryption.decrypt(key) |
|
|
|
{:ok, data |> Enum.into(%{})} |
|
end |
|
end |
|
|
|
defimpl TeamWebsite.PIIEncryption, for: Any do |
|
def encrypt(data, _key), do: {:ok, data, nil} |
|
def decrypt(data, _key), do: {:ok, data} |
|
end |