Skip to content

Commit ae684cd

Browse files
authored
Use XChaCha20 Poly1305 in message encryptor and ignore sign_secret (#36)
1 parent a3df6d4 commit ae684cd

4 files changed

Lines changed: 106 additions & 158 deletions

File tree

lib/plug/crypto/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
defmodule Plug.Crypto.Application do
2+
@moduledoc false
23
use Application
34

45
def start(_, _) do

lib/plug/crypto/message_encryptor.ex

Lines changed: 93 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,38 @@ defmodule Plug.Crypto.MessageEncryptor do
99
This can be used in situations similar to the `Plug.Crypto.MessageVerifier`,
1010
but where you don't want users to be able to determine the value of the payload.
1111
12-
The current algorithm used is AES-GCM-128.
12+
The current algorithm used is XChaCha20-Poly1305.
1313
1414
## Example
1515
1616
iex> secret_key_base = "072d1e0157c008193fe48a670cce031faa4e..."
1717
...> encrypted_cookie_salt = "encrypted cookie"
18-
...> encrypted_signed_cookie_salt = "signed encrypted cookie"
19-
...>
2018
...> secret = KeyGenerator.generate(secret_key_base, encrypted_cookie_salt)
21-
...> sign_secret = KeyGenerator.generate(secret_key_base, encrypted_signed_cookie_salt)
2219
...>
2320
...> data = "José"
24-
...> encrypted = MessageEncryptor.encrypt(data, secret, sign_secret)
25-
...> MessageEncryptor.decrypt(encrypted, secret, sign_secret)
21+
...> encrypted = MessageEncryptor.encrypt(data, secret, "UNUSED")
22+
...> MessageEncryptor.decrypt(encrypted, secret, "UNUSED")
2623
{:ok, "José"}
2724
2825
"""
2926

3027
@doc """
3128
Encrypts a message using authenticated encryption.
3229
30+
The `sign_secret` is currently only used on decryption
31+
for backwards compatibility.
32+
3333
A custom authentication message can be provided.
3434
It defaults to "A128GCM" for backwards compatibility.
3535
"""
3636
def encrypt(message, aad \\ "A128GCM", secret, sign_secret)
37-
when is_binary(message) and (is_binary(aad) or is_list(aad)) and byte_size(secret) > 0 and
37+
when is_binary(message) and (is_binary(aad) or is_list(aad)) and
38+
bit_size(secret) == 256 and
3839
is_binary(sign_secret) do
39-
aes128_gcm_encrypt(message, aad, secret, sign_secret)
40+
iv = :crypto.strong_rand_bytes(24)
41+
{subkey, nonce} = xchacha20_subkey_and_nonce(secret, iv)
42+
{cipher_text, cipher_tag} = block_encrypt(:chacha20_poly1305, subkey, nonce, {aad, message})
43+
"XCP." <> Base.url_encode64(iv <> cipher_tag <> cipher_text, padding: false)
4044
rescue
4145
e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__)
4246
end
@@ -45,67 +49,45 @@ defmodule Plug.Crypto.MessageEncryptor do
4549
Decrypts a message using authenticated encryption.
4650
"""
4751
def decrypt(encrypted, aad \\ "A128GCM", secret, sign_secret)
48-
when is_binary(encrypted) and (is_binary(aad) or is_list(aad)) and byte_size(secret) > 0 and
52+
when is_binary(encrypted) and (is_binary(aad) or is_list(aad)) and
53+
bit_size(secret) in [128, 192, 256] and
4954
is_binary(sign_secret) do
50-
aes128_gcm_decrypt(encrypted, aad, secret, sign_secret)
55+
unguarded_decrypt(encrypted, aad, secret, sign_secret)
5156
rescue
5257
e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__)
5358
end
5459

55-
# Encrypts and authenticates a message using AES128-GCM mode.
56-
#
57-
# A random 128-bit content encryption key (CEK) is generated for
58-
# every message which is then encrypted with `aes_gcm_key_wrap/3`.
59-
defp aes128_gcm_encrypt(plain_text, aad, secret, sign_secret) when bit_size(secret) > 256 do
60-
aes128_gcm_encrypt(plain_text, aad, binary_part(secret, 0, 32), sign_secret)
61-
end
62-
63-
defp aes128_gcm_encrypt(plain_text, aad, secret, sign_secret)
64-
when is_binary(plain_text) and bit_size(secret) in [128, 192, 256] and
65-
is_binary(sign_secret) do
66-
key = :crypto.strong_rand_bytes(16)
67-
iv = :crypto.strong_rand_bytes(12)
68-
{cipher_text, cipher_tag} = block_encrypt(:aes_gcm, key, iv, {aad, plain_text})
69-
encrypted_key = aes_gcm_key_wrap(key, secret, sign_secret)
70-
encode_token("A128GCM", encrypted_key, iv, cipher_text, cipher_tag)
60+
defp unguarded_decrypt("XCP." <> iv_cipher_text_cipher_tag, aad, secret, _sign_secret) do
61+
with {:ok, <<iv::192-bits, cipher_tag::128-bits, cipher_text::binary>>} <-
62+
Base.url_decode64(iv_cipher_text_cipher_tag, padding: false),
63+
{subkey, nonce} = xchacha20_subkey_and_nonce(secret, iv),
64+
plain_text when is_binary(plain_text) <-
65+
block_decrypt(:chacha20_poly1305, subkey, nonce, {aad, cipher_text, cipher_tag}) do
66+
{:ok, plain_text}
67+
else
68+
_ -> :error
69+
end
7170
end
7271

73-
# Verifies and decrypts a message using AES128-GCM mode.
74-
#
75-
# Decryption will never be performed prior to verification.
76-
#
77-
# The encrypted content encryption key (CEK) is decrypted
78-
# with `aes_gcm_key_unwrap/3`.
79-
defp aes128_gcm_decrypt(cipher_text, aad, secret, sign_secret) when bit_size(secret) > 256 do
80-
aes128_gcm_decrypt(cipher_text, aad, binary_part(secret, 0, 32), sign_secret)
72+
# Messages from Plug.Crypto v1.x
73+
defp unguarded_decrypt("QTEyOEdDTQ." <> rest, aad, secret, sign_secret) do
74+
with [encrypted_key, iv, cipher_text, cipher_tag] <- :binary.split(rest, ".", [:global]),
75+
{:ok, encrypted_key} <- Base.url_decode64(encrypted_key, padding: false),
76+
{:ok, iv} when bit_size(iv) === 96 <- Base.url_decode64(iv, padding: false),
77+
{:ok, cipher_text} <- Base.url_decode64(cipher_text, padding: false),
78+
{:ok, cipher_tag} when bit_size(cipher_tag) === 128 <-
79+
Base.url_decode64(cipher_tag, padding: false),
80+
{:ok, key} <- aes_gcm_key_unwrap(encrypted_key, secret, sign_secret),
81+
plain_text when is_binary(plain_text) <-
82+
block_decrypt(:aes_gcm, key, iv, {aad, cipher_text, cipher_tag}) do
83+
{:ok, plain_text}
84+
else
85+
_ -> :error
86+
end
8187
end
8288

83-
defp aes128_gcm_decrypt(cipher_text, aad, secret, sign_secret)
84-
when is_binary(cipher_text) and bit_size(secret) in [128, 192, 256] and
85-
is_binary(sign_secret) do
86-
case decode_token(cipher_text) do
87-
{"A128GCM", encrypted_key, iv, cipher_text, cipher_tag}
88-
when bit_size(iv) === 96 and bit_size(cipher_tag) === 128 ->
89-
encrypted_key
90-
|> aes_gcm_key_unwrap(secret, sign_secret)
91-
|> case do
92-
{:ok, key} ->
93-
block_decrypt(:aes_gcm, key, iv, {aad, cipher_text, cipher_tag})
94-
95-
_ ->
96-
:error
97-
end
98-
|> case do
99-
plain_text when is_binary(plain_text) ->
100-
{:ok, plain_text}
101-
102-
_ ->
103-
:error
104-
end
105-
106-
_ ->
107-
:error
108-
end
89+
defp unguarded_decrypt(_rest, _aad, _secret, _sign_secret) do
90+
:error
10991
end
11092

11193
defp block_encrypt(cipher, key, iv, {aad, payload}) do
@@ -132,32 +114,65 @@ defmodule Plug.Crypto.MessageEncryptor do
132114
"Please make sure it was compiled with the correct OpenSSL/BoringSSL bindings"
133115
end
134116

135-
# Wraps a decrypted content encryption key (CEK) with secret and
136-
# sign_secret using AES GCM mode. Accepts keys of 128, 192, or
137-
# 256 bits based on the length of the secret key.
138-
#
139-
# See: https://tools.ietf.org/html/rfc7518#section-4.7
140-
defp aes_gcm_key_wrap(cek, secret, sign_secret) when bit_size(secret) > 256 do
141-
aes_gcm_key_wrap(cek, binary_part(secret, 0, 32), sign_secret)
117+
defp xchacha20_subkey_and_nonce(<<key::256-bits>>, <<nonce0::128-bits, nonce1::64-bits>>) do
118+
subkey = hchacha20(key, nonce0)
119+
nonce = <<0::32, nonce1::64-bits>>
120+
{subkey, nonce}
142121
end
143122

144-
defp aes_gcm_key_wrap(cek, secret, sign_secret)
145-
when bit_size(cek) in [128, 192, 256] and bit_size(secret) in [128, 192, 256] and
146-
is_binary(sign_secret) do
147-
iv = :crypto.strong_rand_bytes(12)
148-
{cipher_text, cipher_tag} = block_encrypt(:aes_gcm, secret, iv, {sign_secret, cek})
149-
cipher_text <> cipher_tag <> iv
123+
defp hchacha20(<<key::256-bits>>, <<nonce::128-bits>>) do
124+
# ChaCha20 has an internal blocksize of 512-bits (64-bytes).
125+
# Let's use a Mask of random 64-bytes to blind the intermediate keystream.
126+
mask = <<mask_h::128-bits, _::256-bits, mask_t::128-bits>> = :crypto.strong_rand_bytes(64)
127+
128+
<<state_2h::128-bits, _::256-bits, state_2t::128-bits>> =
129+
:crypto.crypto_one_time(:chacha20, key, nonce, mask, true)
130+
131+
<<
132+
x00::32-unsigned-little-integer,
133+
x01::32-unsigned-little-integer,
134+
x02::32-unsigned-little-integer,
135+
x03::32-unsigned-little-integer,
136+
x12::32-unsigned-little-integer,
137+
x13::32-unsigned-little-integer,
138+
x14::32-unsigned-little-integer,
139+
x15::32-unsigned-little-integer
140+
>> =
141+
:crypto.exor(
142+
<<mask_h::128-bits, mask_t::128-bits>>,
143+
<<state_2h::128-bits, state_2t::128-bits>>
144+
)
145+
146+
## The final step of ChaCha20 is `State2 = State0 + State1', so let's
147+
## recover `State1' with subtraction: `State1 = State2 - State0'
148+
<<
149+
y00::32-unsigned-little-integer,
150+
y01::32-unsigned-little-integer,
151+
y02::32-unsigned-little-integer,
152+
y03::32-unsigned-little-integer,
153+
y12::32-unsigned-little-integer,
154+
y13::32-unsigned-little-integer,
155+
y14::32-unsigned-little-integer,
156+
y15::32-unsigned-little-integer
157+
>> = <<"expand 32-byte k", nonce::128-bits>>
158+
159+
<<
160+
x00 - y00::32-unsigned-little-integer,
161+
x01 - y01::32-unsigned-little-integer,
162+
x02 - y02::32-unsigned-little-integer,
163+
x03 - y03::32-unsigned-little-integer,
164+
x12 - y12::32-unsigned-little-integer,
165+
x13 - y13::32-unsigned-little-integer,
166+
x14 - y14::32-unsigned-little-integer,
167+
x15 - y15::32-unsigned-little-integer
168+
>>
150169
end
151170

152171
# Unwraps an encrypted content encryption key (CEK) with secret and
153172
# sign_secret using AES GCM mode. Accepts keys of 128, 192, or 256
154173
# bits based on the length of the secret key.
155174
#
156175
# See: https://tools.ietf.org/html/rfc7518#section-4.7
157-
defp aes_gcm_key_unwrap(wrapped_cek, secret, sign_secret) when bit_size(secret) > 256 do
158-
aes_gcm_key_unwrap(wrapped_cek, binary_part(secret, 0, 32), sign_secret)
159-
end
160-
161176
defp aes_gcm_key_unwrap(wrapped_cek, secret, sign_secret)
162177
when bit_size(secret) in [128, 192, 256] and is_binary(sign_secret) do
163178
wrapped_cek
@@ -175,36 +190,7 @@ defmodule Plug.Crypto.MessageEncryptor do
175190
:error
176191
end
177192
|> case do
178-
cek when bit_size(cek) in [128, 192, 256] ->
179-
{:ok, cek}
180-
181-
_ ->
182-
:error
183-
end
184-
end
185-
186-
defp encode_token(protected, encrypted_key, iv, cipher_text, cipher_tag) do
187-
Base.url_encode64(protected, padding: false)
188-
|> Kernel.<>(".")
189-
|> Kernel.<>(Base.url_encode64(encrypted_key, padding: false))
190-
|> Kernel.<>(".")
191-
|> Kernel.<>(Base.url_encode64(iv, padding: false))
192-
|> Kernel.<>(".")
193-
|> Kernel.<>(Base.url_encode64(cipher_text, padding: false))
194-
|> Kernel.<>(".")
195-
|> Kernel.<>(Base.url_encode64(cipher_tag, padding: false))
196-
end
197-
198-
defp decode_token(token) do
199-
with [protected, encrypted_key, iv, cipher_text, cipher_tag] <-
200-
String.split(token, ".", parts: 5),
201-
{:ok, protected} <- Base.url_decode64(protected, padding: false),
202-
{:ok, encrypted_key} <- Base.url_decode64(encrypted_key, padding: false),
203-
{:ok, iv} <- Base.url_decode64(iv, padding: false),
204-
{:ok, cipher_text} <- Base.url_decode64(cipher_text, padding: false),
205-
{:ok, cipher_tag} <- Base.url_decode64(cipher_tag, padding: false) do
206-
{protected, encrypted_key, iv, cipher_text, cipher_tag}
207-
else
193+
cek when bit_size(cek) in [128, 192, 256] -> {:ok, cek}
208194
_ -> :error
209195
end
210196
end

test/plug/crypto/message_encryptor_test.exs

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,13 @@ defmodule Plug.Crypto.MessageEncryptorTest do
77

88
@right String.duplicate("abcdefgh", 4)
99
@wrong String.duplicate("12345678", 4)
10-
@large String.duplicate(@right, 2)
1110

1211
test "it encrypts/decrypts a message" do
1312
data = <<0, "hełłoworld", 0>>
14-
encrypted = ME.encrypt(data, "right aad", @right, @right)
15-
16-
decrypted = ME.decrypt(encrypted, "right aad", @wrong, @wrong)
17-
assert decrypted == :error
18-
19-
decrypted = ME.decrypt(encrypted, "right aad", @right, @wrong)
20-
assert decrypted == :error
21-
22-
decrypted = ME.decrypt(encrypted, "right aad", @wrong, @right)
23-
assert decrypted == :error
24-
25-
decrypted = ME.decrypt(encrypted, "wrong aad", @right, @right)
26-
assert decrypted == :error
27-
28-
decrypted = ME.decrypt(encrypted, "right aad", @right, @right)
29-
assert decrypted == {:ok, data}
13+
encrypted = ME.encrypt(data, "right aad", @right, "UNUSED")
14+
assert ME.decrypt(encrypted, "right aad", @wrong, "UNUSED") == :error
15+
assert ME.decrypt(encrypted, "wrong aad", @right, "UNUSED") == :error
16+
assert ME.decrypt(encrypted, "right aad", @right, "UNUSED") == {:ok, data}
3017
end
3118

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

38-
test "it uses only the first 32 bytes to encrypt/decrypt" do
39-
data = <<0, "helloworld", 0>>
40-
encrypted = ME.encrypt(<<0, "helloworld", 0>>, @large, @large)
41-
42-
decrypted = ME.decrypt(encrypted, @large, @large)
43-
assert decrypted == {:ok, data}
44-
45-
decrypted = ME.decrypt(encrypted, @right, @large)
46-
assert decrypted == {:ok, data}
25+
@old_message "QTEyOEdDTQ.L85cCXPvSqswNJoxmP5QTopFY83qCPj9czxkwct8b0HDHdC8Qwruhkq3SWw.mmqfbc2dfaMMi6Xi.n1qvYhAUYI0r7-QB6Vw.0jV2tT3U-AQMAQSch2rNsw"
4726

48-
decrypted = ME.decrypt(encrypted, @large, @right)
49-
assert decrypted == :error
50-
51-
decrypted = ME.decrypt(encrypted, @right, @right)
52-
assert decrypted == :error
53-
54-
encrypted = ME.encrypt(<<0, "helloworld", 0>>, @right, @large)
55-
56-
decrypted = ME.decrypt(encrypted, @large, @large)
57-
assert decrypted == {:ok, data}
58-
59-
decrypted = ME.decrypt(encrypted, @right, @large)
60-
assert decrypted == {:ok, data}
61-
62-
decrypted = ME.decrypt(encrypted, @large, @right)
63-
assert decrypted == :error
64-
65-
decrypted = ME.decrypt(encrypted, @right, @right)
66-
assert decrypted == :error
27+
test "it decodes messages from earlier versions" do
28+
data = <<0, "hełłoworld", 0>>
29+
assert ME.decrypt(@old_message, "right aad", @right, @right) == {:ok, data}
30+
assert ME.decrypt(@old_message, "wrong aad", @right, @right) == :error
31+
assert ME.decrypt(@old_message, "right aad", @wrong, @right) == :error
32+
assert ME.decrypt(@old_message, "right aad", @right, @wrong) == :error
33+
assert ME.decrypt(@old_message, "right aad", @wrong, @wrong) == :error
6734
end
6835
end

test/plug/crypto_test.exs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,5 @@ defmodule Plug.CryptoTest do
211211
signed2 = encrypt(@key, "secret", 1, signed_at: 0, key_digest: :sha512)
212212
assert signed1 != signed2
213213
end
214-
215-
test "passes key_length options to key generator" do
216-
signed1 = encrypt(@key, "secret", 1, signed_at: 0, key_length: 16)
217-
signed2 = encrypt(@key, "secret", 1, signed_at: 0, key_length: 32)
218-
assert signed1 != signed2
219-
end
220214
end
221215
end

0 commit comments

Comments
 (0)