Decrypt data that was encrypted with Rails' ActiveSupport::MessageEncryptor

I need to decrypt data that was written from a Rails application and encrypted with ActiveSupport::MessageEncryptor.
I saw that Plug.Crypto.MessageEncryptor appears very similar except that it’s using 128bit instead of ActiveSupport’s 256bit. I tried to modify it but I wasn’t able to decrypt anything successfully yet.
So maybe I’m looking into the wrong direction?

Was anyone able to do something like this or does anyone have a hint regarding this issue?

I also asked on Twitter but I guess here’s a better place for this question. :smiley:

1 Like

Welcome to the Elixir Forum! :smiley:


A lot of cryptographic functionality comes built-in into Erlang, as part of the :crypto module.

From giving the Ruby source code a cursory glance, it seems that (by default) it uses aes-256-gcm, which seems to be supported as one of the algorithms in the :crypto module.

EDIT: It does seem that Plug.Crypto.MessageEncryptor also is able to work with both 128, 192 and 256 bit keys. (see for instance here). So it might just work out of the box. Maybe there is a setting you could try to configure differently?

3 Likes

There’s also this on Hex that might help you if you check out its internals: plug_rails_cookie_session_store | Hex. I believe it’s emulating the ActiveSupport::MessageEncryptor. Not sure if that helps or not, but I used it a few years back in a pet project to share cookie/header data that was encrypted with Rails. Been quite a few years since I looked at it though! :wink:

1 Like

Thank you so much for your responses! :raised_hands:
Somehow I’m not able to get it working though.

Obviously I’m using it wrong somehow or I’m missing some bit.
The ActiveSupport API is pretty trivial and straightforward:

Rails.application.config.secret_key_base = "feedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0de"
secret = "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff"
salt = "deadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeaf"

clear_text = "super secret message"

key = ActiveSupport::KeyGenerator.new(secret).generate_key(salt, ActiveSupport::MessageEncryptor.key_len).freeze
encryptor = ActiveSupport::MessageEncryptor.new(key)
encrypted = encryptor.encrypt_and_sign(clear_text)
puts "encrypted: #{encrypted}"
# => UlY0QW9aNWIxZ3FFRnhQVTZHdjZINEoxK1BUWmEyeTJxQnBSWExkRDlIdz0tLXltTFIzS1pRbHpiTHJxeWRlZXd5Nmc9PQ==--d02836e48b64272ed87961dfb81639ee17ae3537

puts "decrypted: #{encryptor.decrypt_and_verify(encrypted)}"
# => "super secret message"

Now with Phoenix I’m not quite sure how parameters should be set accordingly but verification already fails.
I tried it directly with Plug.Crypto and also like this with the proposed plug_rails_cookie_session_store (since it seems to be using the correct Rails defaults).
The output for function_digest, secure_compare and verify is not necessary but shows at which step this fails, since I didn’t get any helpful error messages except :error. :wink:
Furthmore I tried aes_256_gcm (as seen in the following snippet) and its default aes_256_cbc with the same outcome (:error).

config :my_app, MyApp.Endpoint,
  secret_key_base: "feedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0de"
secret_key_base =
  "feedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0de"

secret =
  "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff"

salt =
  "deadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeaf"

encrypted =
  "UlY0QW9aNWIxZ3FFRnhQVTZHdjZINEoxK1BUWmEyeTJxQnBSWExkRDlIdz0tLXltTFIzS1pRbHpiTHJxeWRlZXd5Nmc9PQ==--d02836e48b64272ed87961dfb81639ee17ae3537"

sign_secret = Plug.Crypto.KeyGenerator.generate(secret_key_base, salt)
key = Plug.Crypto.KeyGenerator.generate(secret_key_base, secret)

[content, digest] = String.split(encrypted, "--") |> IO.inspect(label: "split")
# => ["UlY0QW9aNWIxZ3FFRnhQVTZHdjZINEoxK1BUWmEyeTJxQnBSWExkRDlIdz0tLXltTFIzS1pRbHpiTHJxeWRlZXd5Nmc9PQ==", "d02836e48b64272ed87961dfb81639ee17ae3537"]

function_digest =
  :crypto.mac(:hmac, :sha, secret, content)
  |> Base.encode16(case: :lower)
  |> IO.inspect(label: "function_digest")
# => "cb3673ff2c3ff52c34b2e7b6ec7be5bd0c375e70"

# deps/plug_crypto/lib/plug/crypto.ex
Plug.Crypto.secure_compare(function_digest, digest)
|> IO.inspect(label: "secure_compare")
# => false

PlugRailsCookieSessionStore.MessageVerifier.verify(encrypted, sign_secret)
|> IO.inspect(label: "verified")
# => :error

PlugRailsCookieSessionStore.MessageEncryptor.verify_and_decrypt(
  encrypted,
  key,
  sign_secret,
  :aes_256_gcm
)
|> IO.inspect(label: "verify_and_decrypt")
# => :error

Any help is highly appreciated! :raised_hands:

If I remember correctly, it may have something to do with the length and iterations. Here’s a module I had from years ago that used to work interchangeably between Rails 5 and Phoenix. I seem to recall that Erlang used a longer length than Rails when encrypting/decrypting and you had to set it to :sha. I have not tested this code with newer versions of Rails and haven’t actually looked at it in years. It was just an experiment back in the day. Good luck!

defmodule PhoenixAuth.AuthToken.AuthTokenEncryptor do

  @moduledoc """
    Implements marshalling/encryption and decryption/deserialization of the auth_token

    Equivalent of using ActiveSupport::MessageEncryptor in a Rails 5 app
  """

  alias Plug.Crypto.KeyGenerator
  alias PlugRailsCookieSessionStore.MessageEncryptor

  @secret_key_base  Application.get_env(:phoenix_auth, PhoenixAuthWeb.Endpoint)[:secret_key_base]
  # TODO can make this a more generic MessageEncryptor with a few tweaks
  # TODO move the key args to config params so becomes generate(password, salt, key_opts)
  # TODO password and salt should fallback to @secret_key_base if not set in config
  @key KeyGenerator.generate(@secret_key_base, @secret_key_base, [digest: :sha, length: 32, iterations: 65536])

  def decrypt_and_verify(token) when is_binary(token) do
    token
    |> URI.decode()
    |> MessageEncryptor.verify_and_decrypt(@key, @key)
    |> decode_token()
  end
  def decrypt_and_verify(_), do: {:error, :token_invalid}

  defp decode_token({:ok, decrypted}) do
    decoded = decrypted |> ExMarshal.decode
    {:ok, decoded}
  end
  defp decode_token(_), do: {:error, :encryption_error}

  def encrypt_and_sign(message) when is_binary(message) do
    message
    |> ExMarshal.encode
    |> MessageEncryptor.encrypt_and_sign(@key, @key)
    |> URI.encode()
  end
  def encrypt_and_sign(_), do: {:error, :invalid_message_format}
end
3 Likes

I seem to recall that Erlang used a longer length than Rails when encrypting/decrypting and you had to set it to :sha.

Oh wow! Awesome, you made some very good points. I didn’t see before that the key parameters are different, too. Apart from the diffrent digest the Phoenix plug uses 1000 whereas Rails uses 2**16 => 65_536 iterations, Phoenix uses a length of 32 and Rails seem to use 64.

In your example secret_key_base also seem to be the secret was that intentionally done?
I changed they key parameters accordingly and tried it with both variants but It doesn’t work either way, though. :slightly_frowning_face:

secret_key_base =
  "feedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0defeedc0de"

secret =
  "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff"

salt =
  "deadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeafdeadbeaf"

encrypted =
  "UlY0QW9aNWIxZ3FFRnhQVTZHdjZINEoxK1BUWmEyeTJxQnBSWExkRDlIdz0tLXltTFIzS1pRbHpiTHJxeWRlZXd5Nmc9PQ==--d02836e48b64272ed87961dfb81639ee17ae3537"

sign_secret =
  Plug.Crypto.KeyGenerator.generate(secret_key_base, salt,
    # https://github.com/rails/rails/blob/6d222a280d9e5b11b8d465d6416d2e15a582f114/activesupport/lib/active_support/key_generator.rb#L23
    digest: :sha,
    # https://github.com/rails/rails/blob/6d222a280d9e5b11b8d465d6416d2e15a582f114/activesupport/lib/active_support/key_generator.rb#L22
    length: 64,
    # https://github.com/rails/rails/blob/6d222a280d9e5b11b8d465d6416d2e15a582f114/activesupport/lib/active_support/key_generator.rb#L16
    iterations: 65536
  )

key =
  Plug.Crypto.KeyGenerator.generate(secret_key_base, secret,
    # https://github.com/rails/rails/blob/6d222a280d9e5b11b8d465d6416d2e15a582f114/activesupport/lib/active_support/key_generator.rb#L23
    digest: :sha,
    # https://github.com/rails/rails/blob/6d222a280d9e5b11b8d465d6416d2e15a582f114/activesupport/lib/active_support/key_generator.rb#L22
    length: 64,
    # https://github.com/rails/rails/blob/6d222a280d9e5b11b8d465d6416d2e15a582f114/activesupport/lib/active_support/key_generator.rb#L16
    iterations: 65536
  )

[content, digest] = String.split(encrypted, "--") |> IO.inspect(label: "split")

# => ["UlY0QW9aNWIxZ3FFRnhQVTZHdjZINEoxK1BUWmEyeTJxQnBSWExkRDlIdz0tLXltTFIzS1pRbHpiTHJxeWRlZXd5Nmc9PQ==", "d02836e48b64272ed87961dfb81639ee17ae3537"]

function_digest =
  :crypto.mac(:hmac, :sha, secret, content)
  |> Base.encode16(case: :lower)
  |> IO.inspect(label: "function_digest")

# => "cb3673ff2c3ff52c34b2e7b6ec7be5bd0c375e70"

# deps/plug_crypto/lib/plug/crypto.ex
Plug.Crypto.secure_compare(function_digest, digest)
|> IO.inspect(label: "secure_compare")

# => false

PlugRailsCookieSessionStore.MessageVerifier.verify(encrypted, sign_secret)
|> IO.inspect(label: "verified")

# => :error

PlugRailsCookieSessionStore.MessageEncryptor.verify_and_decrypt(
  encrypted,
  key,
  sign_secret,
  :aes_256_gcm
)
|> IO.inspect(label: "verify_and_decrypt")
# => :error

Try taking the ExMarshal stuff out. Apparently Rails now uses JSON by default.

1 Like

Two other blog posts that may help (although a bit older)

1 Like