198 lines
7.4 KiB
Elixir
198 lines
7.4 KiB
Elixir
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, <<iv::192-bits, cipher_tag::128-bits, cipher_text::binary>>} <-
|
|
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(<<key::256-bits>>, <<nonce0::128-bits, nonce1::64-bits>>) do
|
|
subkey = hchacha20(key, nonce0)
|
|
nonce = <<0::32, nonce1::64-bits>>
|
|
{subkey, nonce}
|
|
end
|
|
|
|
defp hchacha20(<<key::256-bits>>, <<nonce::128-bits>>) 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 = <<mask_h::128-bits, _::256-bits, mask_t::128-bits>> = :crypto.strong_rand_bytes(64)
|
|
|
|
<<state_2h::128-bits, _::256-bits, state_2t::128-bits>> =
|
|
: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(
|
|
<<mask_h::128-bits, mask_t::128-bits>>,
|
|
<<state_2h::128-bits, state_2t::128-bits>>
|
|
)
|
|
|
|
## 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
|
|
<<cipher_text::128-bitstring, cipher_tag::128-bitstring, iv::96-bitstring>> ->
|
|
block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag})
|
|
|
|
<<cipher_text::192-bitstring, cipher_tag::128-bitstring, iv::96-bitstring>> ->
|
|
block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag})
|
|
|
|
<<cipher_text::256-bitstring, cipher_tag::128-bitstring, iv::96-bitstring>> ->
|
|
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
|