Skip to content

Commit

Permalink
Use XChaCha20 Poly1305 in message encryptor and ignore sign_secret (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
josevalim authored Oct 6, 2023
1 parent a3df6d4 commit ae684cd
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 158 deletions.
1 change: 1 addition & 0 deletions lib/plug/crypto/application.ex
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
Expand Down
200 changes: 93 additions & 107 deletions lib/plug/crypto/message_encryptor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
@pled76

pled76 Oct 24, 2023

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.

This comment has been minimized.

Copy link
@josevalim

josevalim Oct 24, 2023

Author Member

@pled76 please provide a complete example. The default key generator we use is 256.

This comment has been minimized.

Copy link
@grzuy

grzuy Feb 27, 2024

Contributor

@pled76 do you have both key_length (to 64) and encryption_salt options set in Plug.Session?

This comment has been minimized.

Copy link
@pablopen

pablopen Apr 2, 2024

@grzuy This was exactly my case, key_length to 64 and encryption_salt set for Plug.Session

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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
57 changes: 12 additions & 45 deletions test/plug/crypto/message_encryptor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,13 @@ defmodule Plug.Crypto.MessageEncryptorTest do

@right String.duplicate("abcdefgh", 4)
@wrong String.duplicate("12345678", 4)
@large String.duplicate(@right, 2)

test "it encrypts/decrypts a message" do
data = <<0, "hełłoworld", 0>>
encrypted = ME.encrypt(data, "right aad", @right, @right)

decrypted = ME.decrypt(encrypted, "right aad", @wrong, @wrong)
assert decrypted == :error

decrypted = ME.decrypt(encrypted, "right aad", @right, @wrong)
assert decrypted == :error

decrypted = ME.decrypt(encrypted, "right aad", @wrong, @right)
assert decrypted == :error

decrypted = ME.decrypt(encrypted, "wrong aad", @right, @right)
assert decrypted == :error

decrypted = ME.decrypt(encrypted, "right aad", @right, @right)
assert decrypted == {:ok, data}
encrypted = ME.encrypt(data, "right aad", @right, "UNUSED")
assert ME.decrypt(encrypted, "right aad", @wrong, "UNUSED") == :error
assert ME.decrypt(encrypted, "wrong aad", @right, "UNUSED") == :error
assert ME.decrypt(encrypted, "right aad", @right, "UNUSED") == {:ok, data}
end

test "it encrypts/decrypts with iodata aad" do
Expand All @@ -35,34 +22,14 @@ defmodule Plug.Crypto.MessageEncryptorTest do
assert ME.decrypt(encrypted, ["right", ?\s, "aad"], @right, @right) == {:ok, data}
end

test "it uses only the first 32 bytes to encrypt/decrypt" do
data = <<0, "helloworld", 0>>
encrypted = ME.encrypt(<<0, "helloworld", 0>>, @large, @large)

decrypted = ME.decrypt(encrypted, @large, @large)
assert decrypted == {:ok, data}

decrypted = ME.decrypt(encrypted, @right, @large)
assert decrypted == {:ok, data}
@old_message "QTEyOEdDTQ.L85cCXPvSqswNJoxmP5QTopFY83qCPj9czxkwct8b0HDHdC8Qwruhkq3SWw.mmqfbc2dfaMMi6Xi.n1qvYhAUYI0r7-QB6Vw.0jV2tT3U-AQMAQSch2rNsw"

decrypted = ME.decrypt(encrypted, @large, @right)
assert decrypted == :error

decrypted = ME.decrypt(encrypted, @right, @right)
assert decrypted == :error

encrypted = ME.encrypt(<<0, "helloworld", 0>>, @right, @large)

decrypted = ME.decrypt(encrypted, @large, @large)
assert decrypted == {:ok, data}

decrypted = ME.decrypt(encrypted, @right, @large)
assert decrypted == {:ok, data}

decrypted = ME.decrypt(encrypted, @large, @right)
assert decrypted == :error

decrypted = ME.decrypt(encrypted, @right, @right)
assert decrypted == :error
test "it decodes messages from earlier versions" do
data = <<0, "hełłoworld", 0>>
assert ME.decrypt(@old_message, "right aad", @right, @right) == {:ok, data}
assert ME.decrypt(@old_message, "wrong aad", @right, @right) == :error
assert ME.decrypt(@old_message, "right aad", @wrong, @right) == :error
assert ME.decrypt(@old_message, "right aad", @right, @wrong) == :error
assert ME.decrypt(@old_message, "right aad", @wrong, @wrong) == :error
end
end
6 changes: 0 additions & 6 deletions test/plug/crypto_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,5 @@ defmodule Plug.CryptoTest do
signed2 = encrypt(@key, "secret", 1, signed_at: 0, key_digest: :sha512)
assert signed1 != signed2
end

test "passes key_length options to key generator" do
signed1 = encrypt(@key, "secret", 1, signed_at: 0, key_length: 16)
signed2 = encrypt(@key, "secret", 1, signed_at: 0, key_length: 32)
assert signed1 != signed2
end
end
end

0 comments on commit ae684cd

Please sign in to comment.