Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 62 additions & 20 deletions lib/plug_rails_cookie_session_store.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,18 @@ defmodule PlugRailsCookieSessionStore do

## Options

* `:use_authenticated_encryption` - specify whether to use authenticated encryption,
defaults to false. This is the default behaviour in Rails 5.2 and higher. If true,
`:authenticated_encryption_salt` is required. If false, `:encryption_salt` and `:signing_salt`
are required;

* `:authenticated_encryption_salt` - a salt used with `conn.secret_key_base` to generate
a key for AEAD encrypting/decrypting a cookie;

* `:encrypt` - specify whether to encrypt cookies, defaults to true.
When this option is false, the cookie is still signed, meaning it
can't be tempered with but its contents can be read;
can't be tempered with but its contents can be read. Only applies if
if `:use_authenticated_encryption` is false.

* `:encryption_salt` - a salt used with `conn.secret_key_base` to generate
a key for encrypting/decrypting a cookie;
Expand Down Expand Up @@ -59,8 +68,7 @@ defmodule PlugRailsCookieSessionStore do
alias PlugRailsCookieSessionStore.MessageEncryptor

def init(opts) do
encryption_salt = check_encryption_salt(opts)
signing_salt = check_signing_salt(opts)
{use_authenticated_encryption, {authenticated_encryption_salt, encryption_salt, signing_salt}} = parse_salts(opts)

iterations = Keyword.get(opts, :key_iterations, 1000)
length = Keyword.get(opts, :key_length, 32)
Expand All @@ -70,6 +78,8 @@ defmodule PlugRailsCookieSessionStore do
serializer = check_serializer(opts[:serializer] || :external_term_format)

%{
use_authenticated_encryption: use_authenticated_encryption,
authenticated_encryption_salt: authenticated_encryption_salt,
encryption_salt: encryption_salt,
signing_salt: signing_salt,
key_opts: key_opts,
Expand All @@ -81,14 +91,21 @@ defmodule PlugRailsCookieSessionStore do
key_opts = opts.key_opts
cookie = cookie |> URI.decode_www_form()

if key = opts.encryption_salt do
MessageEncryptor.verify_and_decrypt(
if opts.use_authenticated_encryption do
MessageEncryptor.authenticate_and_decrypt(
cookie,
derive(conn, key, key_opts),
derive(conn, opts.signing_salt, key_opts)
derive(conn, opts.authenticated_encryption_salt, key_opts |> Keyword.put(:key_digest, :sha1))
Copy link

@tomekowal tomekowal Jun 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
derive(conn, opts.authenticated_encryption_salt, key_opts |> Keyword.put(:key_digest, :sha1))
derive(conn, opts.authenticated_encryption_salt, key_opts |> Keyword.put(:digest, :sha))

I've tried use this branch with real Rails cookie. I've discovered that secret is generated differently. It was because Plug.Crypto.KeyGenerator uses key :digest instead of :key_digest and because sha1 algorithm has to be represented as just :sha (without the "1").
Not sure if it changed recently.
I am on Erlang 23.2.5

If a different key was intentional, it is not reflected in the derive function.

)
else
MessageVerifier.verify(cookie, derive(conn, opts.signing_salt, key_opts))
if key = opts.encryption_salt do
MessageEncryptor.verify_and_decrypt(
cookie,
derive(conn, key, key_opts),
derive(conn, opts.signing_salt, key_opts)
)
else
MessageVerifier.verify(cookie, derive(conn, opts.signing_salt, key_opts))
end
end
|> decode(opts.serializer)
end
Expand All @@ -97,14 +114,21 @@ defmodule PlugRailsCookieSessionStore do
binary = encode(term, opts.serializer)
key_opts = opts.key_opts

if key = opts.encryption_salt do
MessageEncryptor.encrypt_and_sign(
if opts.use_authenticated_encryption do
MessageEncryptor.encrypt_and_authenticate(
binary,
derive(conn, key, key_opts),
derive(conn, opts.signing_salt, key_opts)
derive(conn, opts.authenticated_encryption_salt, key_opts)
)
else
MessageVerifier.sign(binary, derive(conn, opts.signing_salt, key_opts))
if key = opts.encryption_salt do
MessageEncryptor.encrypt_and_sign(
binary,
derive(conn, key, key_opts),
derive(conn, opts.signing_salt, key_opts)
)
else
MessageVerifier.sign(binary, derive(conn, opts.signing_salt, key_opts))
end
end
|> URI.encode_www_form()
end
Expand Down Expand Up @@ -150,24 +174,42 @@ defmodule PlugRailsCookieSessionStore do

defp validate_secret_key_base(secret_key_base), do: secret_key_base

defp check_signing_salt(opts) do
if Keyword.get(opts, :signing_with_salt, true) do
case opts[:signing_salt] do
nil -> raise ArgumentError, "cookie store expects :signing_salt as option"
salt -> salt
end
defp parse_salts(opts) do
use_authenticated_encryption = Keyword.get(opts, :use_authenticated_encryption, false)
{use_authenticated_encryption, parse_salts(use_authenticated_encryption, opts)}
end

defp parse_salts(true, opts) do
{check_authenticated_encryption_salt(opts), nil, nil}
end

defp parse_salts(false, opts) do
{nil, check_encryption_salt(opts), check_signing_salt(opts)}
end

defp check_authenticated_encryption_salt(opts) do
case opts[:authenticated_encryption_salt] do
nil -> raise ArgumentError, "cookie store expects :authenticated_encryption_salt as option"
salt -> salt
end
end

defp check_encryption_salt(opts) do
if Keyword.get(opts, :encrypt, true) do
case opts[:encryption_salt] do
nil -> raise ArgumentError, "encrypted cookie store expects :encryption_salt as option"
nil -> raise ArgumentError, "cookie store expects :encryption_salt as option"
salt -> salt
end
end
end

defp check_signing_salt(opts) do
case opts[:signing_salt] do
nil -> raise ArgumentError, "cookie store expects :signing_salt as option"
salt -> salt
end
end

defp check_serializer(serializer) when is_atom(serializer), do: serializer

defp check_serializer(_),
Expand Down
28 changes: 28 additions & 0 deletions lib/plug_rails_cookie_session_store/message_encryptor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ defmodule PlugRailsCookieSessionStore.MessageEncryptor do
end
end

@doc """
Encrypts and signs a message.
"""
def encrypt_and_authenticate(message, secret, cipher \\ :aes_gcm)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def encrypt_and_authenticate(message, secret, cipher \\ :aes_gcm)
def encrypt_and_authenticate(message, secret, cipher \\ :aes_256_gcm)

Rails seems to use AES 256 GCM: https://github.com/rails/rails/pull/28132/files#diff-744c15344fa1f284281b429673de936cR231

which seems to be a cypher type in :crypto:

https://github.com/erlang/otp/blob/master/lib/crypto/src/crypto.erl#L495

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cconstantin can you take a look at this? :)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gstokkink @4xposed what this is missing is a passing test for rails 5.2. I got stuck on that and never had time to get back and complete. Any chance you can contribute that?

when is_binary(message) and is_binary(secret) do
iv = :crypto.strong_rand_bytes(16)

{message, auth_tag} = encrypt({"", pad_message(message), 16}, cipher, secret, iv)
message
|> Base.encode64()
|> Kernel.<>("--#{Base.encode64(iv)}")
|> Kernel.<>("--#{Base.encode64(auth_tag)}")
end

@doc """
Decrypts and authenticates a message.
"""
def authenticate_and_decrypt(encrypted, secret, cipher \\ :aes_gcm)
when is_binary(encrypted) and is_binary(secret) do
[encrypted, iv, auth_tag] = String.split(encrypted, "--") |> Enum.map(&Base.decode64!/1)
result = {"", encrypted, auth_tag} |> decrypt(cipher, secret, iv) |> unpad_message
result
end

defp encrypt(message, cipher, secret, iv) do
:crypto.block_encrypt(cipher, trim_secret(secret), iv, message)
end
Expand All @@ -66,6 +90,10 @@ defmodule PlugRailsCookieSessionStore.MessageEncryptor do
msg <> :binary.copy(<<padding_size>>, padding_size)
end

defp unpad_message(:error) do
:error
end

defp unpad_message(msg) do
padding_size = :binary.last(msg)
if padding_size <= 16 do
Expand Down
19 changes: 19 additions & 0 deletions test/plug_rails_cookie_session_store_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ defmodule PlugRailsCookieSessionStoreTest do
)
@encrypted_opts Plug.Session.init(@default_opts)

@authenticated_encrypted_opts Plug.Session.init(@default_opts
|> Keyword.put(:use_authenticated_encryption, true)
|> Keyword.put(:authenticated_encryption_salt, "authenticated encrypted cookie"))

defmodule CustomSerializer do
def encode(%{"foo" => "bar"}), do: {:ok, "encoded session"}
def encode(%{foo: :bar}), do: {:ok, "another encoded session"}
Expand Down Expand Up @@ -46,6 +50,12 @@ defmodule PlugRailsCookieSessionStoreTest do
|> fetch_session
end

defp authenticated_encrypt_conn(conn) do
put_in(conn.secret_key_base, "edffda9d151781024e5a40d0d68d44f6")
|> Plug.Session.call(@authenticated_encrypted_opts)
|> fetch_session
end

defp custom_serialize_conn(conn) do
put_in(conn.secret_key_base, @secret)
|> Plug.Session.call(@custom_serializer_opts)
Expand Down Expand Up @@ -228,4 +238,13 @@ defmodule PlugRailsCookieSessionStoreTest do
|> custom_serialize_conn()
|> get_session(:foo) == nil
end

@tag :wip
test "deserializes Rails >5.2 session cookie" do
assert conn(:get, "/")
|> put_req_cookie("foobar", "XMxMwUhyiqs5gHWnmFQaWqRGg0vdvy4KHcKbhTUuGl2%2FAuFgLckh0grWkOh7s0zAd0bPeRlXSxZkGv0%3D--djXWCUYYPM5HFzUu--gYdZB9mHt5C0fkTjvnAZpg%3D%3D")
|> authenticated_encrypt_conn()
|> get_session(:foo) == 123

end
end
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,40 @@ defmodule PlugRailsCookieSessionStore.MessageEncryptorTest do
decrypted = ME.verify_and_decrypt(encrypted, @right, @right)
assert decrypted == :error
end

test "it authenticates and encrypts/decrypts a message" do
#decrypted = ME.verify_and_decrypt("gfYQ/Lf3k3g2UbqVYUGkJZ2VVW5Elnw+Q6hYM/vA09MOvs4bswNiJ/SGRiyamaQJ5u8p0hPKkgMPZ3o=--M6q9L9Aj90r1oDdL--HJ0Ce+OpOsCU/Q7Mb/k2jA==", "authenticated encrypted cookie", "", :aes_gcm)
#assert decrypted == :error

data = <<0, "hełłoworld", 0>>
encrypted = ME.encrypt_and_authenticate(<<0, "hełłoworld", 0>>, @right)

decrypted = ME.authenticate_and_decrypt(encrypted, @wrong)
assert decrypted == :error

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

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

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

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

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

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

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

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