-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Use XChaCha20 Poly1305 in message encryptor and ignore sign_secret (#36)
- Loading branch information
Showing
4 changed files
with
106 additions
and
158 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
defmodule Plug.Crypto.Application do | ||
@moduledoc false | ||
use Application | ||
|
||
def start(_, _) do | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,34 +9,38 @@ defmodule Plug.Crypto.MessageEncryptor do | |
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 AES-GCM-128. | ||
The current algorithm used is XChaCha20-Poly1305. | ||
## Example | ||
iex> secret_key_base = "072d1e0157c008193fe48a670cce031faa4e..." | ||
...> encrypted_cookie_salt = "encrypted cookie" | ||
...> encrypted_signed_cookie_salt = "signed encrypted cookie" | ||
...> | ||
...> secret = KeyGenerator.generate(secret_key_base, encrypted_cookie_salt) | ||
...> sign_secret = KeyGenerator.generate(secret_key_base, encrypted_signed_cookie_salt) | ||
...> | ||
...> data = "José" | ||
...> encrypted = MessageEncryptor.encrypt(data, secret, sign_secret) | ||
...> MessageEncryptor.decrypt(encrypted, secret, sign_secret) | ||
...> 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 byte_size(secret) > 0 and | ||
when is_binary(message) and (is_binary(aad) or is_list(aad)) and | ||
bit_size(secret) == 256 and | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
josevalim
Author
Member
|
||
is_binary(sign_secret) do | ||
aes128_gcm_encrypt(message, aad, secret, sign_secret) | ||
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 | ||
|
@@ -45,67 +49,45 @@ defmodule Plug.Crypto.MessageEncryptor do | |
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 byte_size(secret) > 0 and | ||
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 | ||
aes128_gcm_decrypt(encrypted, aad, secret, sign_secret) | ||
unguarded_decrypt(encrypted, aad, secret, sign_secret) | ||
rescue | ||
e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) | ||
end | ||
|
||
# Encrypts and authenticates a message using AES128-GCM mode. | ||
# | ||
# A random 128-bit content encryption key (CEK) is generated for | ||
# every message which is then encrypted with `aes_gcm_key_wrap/3`. | ||
defp aes128_gcm_encrypt(plain_text, aad, secret, sign_secret) when bit_size(secret) > 256 do | ||
aes128_gcm_encrypt(plain_text, aad, binary_part(secret, 0, 32), sign_secret) | ||
end | ||
|
||
defp aes128_gcm_encrypt(plain_text, aad, secret, sign_secret) | ||
when is_binary(plain_text) and bit_size(secret) in [128, 192, 256] and | ||
is_binary(sign_secret) do | ||
key = :crypto.strong_rand_bytes(16) | ||
iv = :crypto.strong_rand_bytes(12) | ||
{cipher_text, cipher_tag} = block_encrypt(:aes_gcm, key, iv, {aad, plain_text}) | ||
encrypted_key = aes_gcm_key_wrap(key, secret, sign_secret) | ||
encode_token("A128GCM", encrypted_key, iv, cipher_text, cipher_tag) | ||
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 | ||
|
||
# Verifies and decrypts a message using AES128-GCM mode. | ||
# | ||
# Decryption will never be performed prior to verification. | ||
# | ||
# The encrypted content encryption key (CEK) is decrypted | ||
# with `aes_gcm_key_unwrap/3`. | ||
defp aes128_gcm_decrypt(cipher_text, aad, secret, sign_secret) when bit_size(secret) > 256 do | ||
aes128_gcm_decrypt(cipher_text, aad, binary_part(secret, 0, 32), sign_secret) | ||
# 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 aes128_gcm_decrypt(cipher_text, aad, secret, sign_secret) | ||
when is_binary(cipher_text) and bit_size(secret) in [128, 192, 256] and | ||
is_binary(sign_secret) do | ||
case decode_token(cipher_text) do | ||
{"A128GCM", encrypted_key, iv, cipher_text, cipher_tag} | ||
when bit_size(iv) === 96 and bit_size(cipher_tag) === 128 -> | ||
encrypted_key | ||
|> aes_gcm_key_unwrap(secret, sign_secret) | ||
|> case do | ||
{:ok, key} -> | ||
block_decrypt(:aes_gcm, key, iv, {aad, cipher_text, cipher_tag}) | ||
|
||
_ -> | ||
:error | ||
end | ||
|> case do | ||
plain_text when is_binary(plain_text) -> | ||
{:ok, plain_text} | ||
|
||
_ -> | ||
:error | ||
end | ||
|
||
_ -> | ||
:error | ||
end | ||
defp unguarded_decrypt(_rest, _aad, _secret, _sign_secret) do | ||
:error | ||
end | ||
|
||
defp block_encrypt(cipher, key, iv, {aad, payload}) do | ||
|
@@ -132,32 +114,65 @@ defmodule Plug.Crypto.MessageEncryptor do | |
"Please make sure it was compiled with the correct OpenSSL/BoringSSL bindings" | ||
end | ||
|
||
# Wraps a decrypted 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_wrap(cek, secret, sign_secret) when bit_size(secret) > 256 do | ||
aes_gcm_key_wrap(cek, binary_part(secret, 0, 32), sign_secret) | ||
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 aes_gcm_key_wrap(cek, secret, sign_secret) | ||
when bit_size(cek) in [128, 192, 256] and bit_size(secret) in [128, 192, 256] and | ||
is_binary(sign_secret) do | ||
iv = :crypto.strong_rand_bytes(12) | ||
{cipher_text, cipher_tag} = block_encrypt(:aes_gcm, secret, iv, {sign_secret, cek}) | ||
cipher_text <> cipher_tag <> iv | ||
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) > 256 do | ||
aes_gcm_key_unwrap(wrapped_cek, binary_part(secret, 0, 32), sign_secret) | ||
end | ||
|
||
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 | ||
|
@@ -175,36 +190,7 @@ defmodule Plug.Crypto.MessageEncryptor do | |
:error | ||
end | ||
|> case do | ||
cek when bit_size(cek) in [128, 192, 256] -> | ||
{:ok, cek} | ||
|
||
_ -> | ||
:error | ||
end | ||
end | ||
|
||
defp encode_token(protected, encrypted_key, iv, cipher_text, cipher_tag) do | ||
Base.url_encode64(protected, padding: false) | ||
|> Kernel.<>(".") | ||
|> Kernel.<>(Base.url_encode64(encrypted_key, padding: false)) | ||
|> Kernel.<>(".") | ||
|> Kernel.<>(Base.url_encode64(iv, padding: false)) | ||
|> Kernel.<>(".") | ||
|> Kernel.<>(Base.url_encode64(cipher_text, padding: false)) | ||
|> Kernel.<>(".") | ||
|> Kernel.<>(Base.url_encode64(cipher_tag, padding: false)) | ||
end | ||
|
||
defp decode_token(token) do | ||
with [protected, encrypted_key, iv, cipher_text, cipher_tag] <- | ||
String.split(token, ".", parts: 5), | ||
{:ok, protected} <- Base.url_decode64(protected, padding: false), | ||
{:ok, encrypted_key} <- Base.url_decode64(encrypted_key, padding: false), | ||
{:ok, iv} <- Base.url_decode64(iv, padding: false), | ||
{:ok, cipher_text} <- Base.url_decode64(cipher_text, padding: false), | ||
{:ok, cipher_tag} <- Base.url_decode64(cipher_tag, padding: false) do | ||
{protected, encrypted_key, iv, cipher_text, cipher_tag} | ||
else | ||
cek when bit_size(cek) in [128, 192, 256] -> {:ok, cek} | ||
_ -> :error | ||
end | ||
end | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
KeyGenerator gives us 512 length here, so we get
[error] GenServer #PID<0.598.0> terminating
** (FunctionClauseError) no function clause matching in Plug.Crypto.MessageEncryptor.decrypt/4
So neither encrypt nor decrypt works with Plug.Session any more.