defmodule Plug.Crypto.MessageEncryptor do @moduledoc ~S""" `MessageEncryptor` is a simple way to encrypt values which get stored somewhere you don't trust. The encrypted key, initialization vector, cipher text, and cipher tag are base64url encoded and returned to you. This can be used in situations similar to the `Plug.Crypto.MessageVerifier`, but where you don't want users to be able to determine the value of the payload. The current algorithm used is XChaCha20-Poly1305. ## Example iex> secret_key_base = "072d1e0157c008193fe48a670cce031faa4e..." ...> encrypted_cookie_salt = "encrypted cookie" ...> secret = KeyGenerator.generate(secret_key_base, encrypted_cookie_salt) ...> ...> data = "José" ...> encrypted = MessageEncryptor.encrypt(data, secret, "UNUSED") ...> MessageEncryptor.decrypt(encrypted, secret, "UNUSED") {:ok, "José"} """ @doc """ Encrypts a message using authenticated encryption. The `sign_secret` is currently only used on decryption for backwards compatibility. A custom authentication message can be provided. It defaults to "A128GCM" for backwards compatibility. """ def encrypt(message, aad \\ "A128GCM", secret, sign_secret) when is_binary(message) and (is_binary(aad) or is_list(aad)) and bit_size(secret) == 256 and is_binary(sign_secret) do iv = :crypto.strong_rand_bytes(24) {subkey, nonce} = xchacha20_subkey_and_nonce(secret, iv) {cipher_text, cipher_tag} = block_encrypt(:chacha20_poly1305, subkey, nonce, {aad, message}) "XCP." <> Base.url_encode64(iv <> cipher_tag <> cipher_text, padding: false) rescue e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) end @doc """ Decrypts a message using authenticated encryption. """ def decrypt(encrypted, aad \\ "A128GCM", secret, sign_secret) when is_binary(encrypted) and (is_binary(aad) or is_list(aad)) and bit_size(secret) in [128, 192, 256] and is_binary(sign_secret) do unguarded_decrypt(encrypted, aad, secret, sign_secret) rescue e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) end defp unguarded_decrypt("XCP." <> iv_cipher_text_cipher_tag, aad, secret, _sign_secret) do with {:ok, <>} <- Base.url_decode64(iv_cipher_text_cipher_tag, padding: false), {subkey, nonce} = xchacha20_subkey_and_nonce(secret, iv), plain_text when is_binary(plain_text) <- block_decrypt(:chacha20_poly1305, subkey, nonce, {aad, cipher_text, cipher_tag}) do {:ok, plain_text} else _ -> :error end end # Messages from Plug.Crypto v1.x defp unguarded_decrypt("QTEyOEdDTQ." <> rest, aad, secret, sign_secret) do with [encrypted_key, iv, cipher_text, cipher_tag] <- :binary.split(rest, ".", [:global]), {:ok, encrypted_key} <- Base.url_decode64(encrypted_key, padding: false), {:ok, iv} when bit_size(iv) === 96 <- Base.url_decode64(iv, padding: false), {:ok, cipher_text} <- Base.url_decode64(cipher_text, padding: false), {:ok, cipher_tag} when bit_size(cipher_tag) === 128 <- Base.url_decode64(cipher_tag, padding: false), {:ok, key} <- aes_gcm_key_unwrap(encrypted_key, secret, sign_secret), plain_text when is_binary(plain_text) <- block_decrypt(:aes_gcm, key, iv, {aad, cipher_text, cipher_tag}) do {:ok, plain_text} else _ -> :error end end defp unguarded_decrypt(_rest, _aad, _secret, _sign_secret) do :error end defp block_encrypt(cipher, key, iv, {aad, payload}) do cipher = cipher_alias(cipher, bit_size(key)) :crypto.crypto_one_time_aead(cipher, key, iv, payload, aad, true) catch :error, :notsup -> raise_notsup(cipher) end defp block_decrypt(cipher, key, iv, {aad, payload, tag}) do cipher = cipher_alias(cipher, bit_size(key)) :crypto.crypto_one_time_aead(cipher, key, iv, payload, aad, tag, false) catch :error, :notsup -> raise_notsup(cipher) end defp cipher_alias(:aes_gcm, 128), do: :aes_128_gcm defp cipher_alias(:aes_gcm, 192), do: :aes_192_gcm defp cipher_alias(:aes_gcm, 256), do: :aes_256_gcm defp cipher_alias(other, _), do: other defp raise_notsup(algo) do raise "the algorithm #{inspect(algo)} is not supported by your Erlang/OTP installation. " <> "Please make sure it was compiled with the correct OpenSSL/BoringSSL bindings" end defp xchacha20_subkey_and_nonce(<>, <>) do subkey = hchacha20(key, nonce0) nonce = <<0::32, nonce1::64-bits>> {subkey, nonce} end defp hchacha20(<>, <>) do # ChaCha20 has an internal blocksize of 512-bits (64-bytes). # Let's use a Mask of random 64-bytes to blind the intermediate keystream. mask = <> = :crypto.strong_rand_bytes(64) <> = :crypto.crypto_one_time(:chacha20, key, nonce, mask, true) << x00::32-unsigned-little-integer, x01::32-unsigned-little-integer, x02::32-unsigned-little-integer, x03::32-unsigned-little-integer, x12::32-unsigned-little-integer, x13::32-unsigned-little-integer, x14::32-unsigned-little-integer, x15::32-unsigned-little-integer >> = :crypto.exor( <>, <> ) ## The final step of ChaCha20 is `State2 = State0 + State1', so let's ## recover `State1' with subtraction: `State1 = State2 - State0' << y00::32-unsigned-little-integer, y01::32-unsigned-little-integer, y02::32-unsigned-little-integer, y03::32-unsigned-little-integer, y12::32-unsigned-little-integer, y13::32-unsigned-little-integer, y14::32-unsigned-little-integer, y15::32-unsigned-little-integer >> = <<"expand 32-byte k", nonce::128-bits>> << x00 - y00::32-unsigned-little-integer, x01 - y01::32-unsigned-little-integer, x02 - y02::32-unsigned-little-integer, x03 - y03::32-unsigned-little-integer, x12 - y12::32-unsigned-little-integer, x13 - y13::32-unsigned-little-integer, x14 - y14::32-unsigned-little-integer, x15 - y15::32-unsigned-little-integer >> end # Unwraps an encrypted content encryption key (CEK) with secret and # sign_secret using AES GCM mode. Accepts keys of 128, 192, or 256 # bits based on the length of the secret key. # # See: https://tools.ietf.org/html/rfc7518#section-4.7 defp aes_gcm_key_unwrap(wrapped_cek, secret, sign_secret) when bit_size(secret) in [128, 192, 256] and is_binary(sign_secret) do wrapped_cek |> case do <> -> block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag}) <> -> block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag}) <> -> block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag}) _ -> :error end |> case do cek when bit_size(cek) in [128, 192, 256] -> {:ok, cek} _ -> :error end end end