Skip to content

Add purpose and expiry to messages encrypted using Message Encryptor#29599

Merged
kaspth merged 1 commit intorails:masterfrom
assain:add_meta_data_to_message_encryptor
Jul 19, 2017
Merged

Add purpose and expiry to messages encrypted using Message Encryptor#29599
kaspth merged 1 commit intorails:masterfrom
assain:add_meta_data_to_message_encryptor

Conversation

@assain
Copy link
Contributor

@assain assain commented Jun 28, 2017

Summary

@kaspth
Allow messages encrypted using Message Encryptor to have optional, associated metadata, which will be encrypted along with the message. The optional metadata supported are: expires_in, expires_at and purpose.

@rails-bot
Copy link

Thanks for the pull request, and welcome! The Rails team is excited to review your changes, and you should hear from @eileencodes (or someone else) soon.

If any changes to this PR are deemed necessary, please add them as extra commits. This ensures that the reviewer can see what has changed since they last reviewed the code. Due to the way GitHub handles out-of-date commits, this should also make it reasonably obvious what issues have or haven't been addressed. Large or tricky changes may require several passes of review and changes.

This repository is being automatically checked for code quality issues using Code Climate. You can see results for this analysis in the PR status below. Newly introduced issues should be fixed before a Pull Request is considered ready to review.

Please see the contribution instructions for more information.

@rafaelfranca rafaelfranca assigned kaspth and unassigned eileencodes Jun 28, 2017
Copy link
Contributor

Choose a reason for hiding this comment

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

When I said metadata I didn't mean users could any metadata, but a name to convey what the purpose and expiry are at a higher level 😊

For this to work we also need to add support for purpose when decrypting and verifying, so the original purpose within the message can be compared against (the expiry has the info within it). See the SignedGlobalID class for the API we need.

Copy link
Contributor

Choose a reason for hiding this comment

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

A note about backwardscompatibility: the tests still output the same encrypted messages now, but should they fail that means we've broken backwardscompatibility. So keep an eye out for that 😊

@assain assain changed the title Optional metadata feature for message encryptor. Verify expiry and purpose metadata associated with a message Jul 6, 2017
Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

Here's an initial review. Looks like we have a lot of the pieces in place already 👏

Copy link
Contributor

Choose a reason for hiding this comment

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

✂️

Copy link
Contributor

Choose a reason for hiding this comment

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

Needs to be a keyword arg: def decrypt_and_verify(value, purpose: nil)

Copy link
Contributor

Choose a reason for hiding this comment

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

_decrypt deals with the raw decryption process. I think the extra verifications should be in decrypt_and_verify

Copy link
Contributor

Choose a reason for hiding this comment

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

Something rubs me the wrong way about the encryptor being happy to throw an exception for every occasion. What would the ramifications be if the encryptor just returned nil if it didn't pass verification?

Copy link
Contributor

Choose a reason for hiding this comment

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

People also need to be able to pass in :purpose, :expires_in and :expires_at when calling encrypt_and_sign otherwise it's rather secret that we have the support.

Copy link
Contributor

Choose a reason for hiding this comment

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

In fact let's wrap the payload metadata handling in an ActiveSupport::Messages::Metadata class. Here's the rough structure for it (as I see it):

# active_support/messages/metadata.rb
module ActiveSupport
  module Messages
    class Metadata # :nodoc:
      def initialize(expires_at, purpose)
        @expires_at, @purpose = expires_at, purpose
      end

      class << self
        def wrap(message, expires_at: nil, expires_in: nil, purpose: nil)
          { "value" => message, "_rails" => { "exp" => pick_expiry(expires_at, expires_in), "pur" => purpose } }
        end

        def verify(message, purpose)
          metadata = extract_metadata(message)
          message["value"] if metadata.nil? || (metadata.match?(purpose) && metadata.fresh?)
        end

        private
          def pick_expiry(expires_at, expires_in)
            # Needs to use iso8601(3) and have expires_at take precedence. See SignedGlobalID and Active Storage for inspiration.
          end

          def extract_metadata(message)
            # Check if the metadata came from us and create a new instance.
          end
      end

      def match?(purpose)
      end

      def fresh?
      end
    end
  end
end

We'll need to use string keys throughout such that messages encoded with JSON will work when decoded. We can then use this class in both the verifier and encryptor.

The _rails key stores the metadata and lets us handle backwardscompatibility. E.g. if that key is missing we can't provide the extra metadata verification, so we should just skip it.

Copy link
Contributor

Choose a reason for hiding this comment

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

This should be encryptor.encrypt_and_sign(message, purpose: "bar") and the decrypted message should then just be "foo" (remember to keep case consistency too).

@assain assain changed the title Verify expiry and purpose metadata associated with a message Add purpose and expiry metadata support to messages encrypted using Message Encryptor Jul 11, 2017
@assain assain changed the title Add purpose and expiry metadata support to messages encrypted using Message Encryptor Add purpose and expiry to messages encrypted using Message Encryptor Jul 11, 2017
Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

Good progress! 👏

Had some more comments on the implementation specifics. I'll check back with more comments on the tests, so we make sure backwardscompatibility is still there.

Copy link
Contributor

Choose a reason for hiding this comment

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

We prefer standard ifs to ternaries (especially the hard to read double-ternaries). This would be much clearer to read (albeit the extra lines):

metadata = extract_metadata(message)

if metadata && metadata.match?(purpose) && metadata.fresh?
  message["value"]
else
  message
end

Copy link
Contributor

Choose a reason for hiding this comment

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

Delete the newline after private.

Copy link
Contributor

Choose a reason for hiding this comment

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

Again, double ternaries are extremely tough for others to read. That cost isn't worth the terseness, so just use a standard if … elsif.

Copy link
Contributor

Choose a reason for hiding this comment

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

self here is Metadata so we can just say new instead of Metadata.new.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this extra abstraction is pulling its weight. When I read the next line I'm confused as to where message["_rails"] came from. I'd just put the method body here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ruby has deprecated has_key? in favor of key?.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think == is the more apt method to compare with here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Use assert_nil to spare us an exception on the eventual Minitest 6.

Copy link
Contributor

Choose a reason for hiding this comment

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

We're already in the ActiveSupport namespace here. So I think we can shorten this to Messages::Metadata. Same above.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nicely remembered 👍

Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

Found some more things I'd like us to clean up in the tests. Once that's done, I'll look at which types of tests we're missing 😊

Copy link
Contributor

Choose a reason for hiding this comment

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

Let's move this if into wrap and make it return the original message in case no metadata options have been passed. It makes sense for the metadata object to handle whether or not to wrap — and we're going to need this if for the verifier as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

Lets just inline this message variable.

Copy link
Contributor

Choose a reason for hiding this comment

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

Lets rename value to data — clarifies that this decrypts raw encrypted data.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ruby returns nil by default, so skip the else branch.

Copy link
Contributor

Choose a reason for hiding this comment

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

Add a newline before here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Newline above here as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

Newline above here, before the other travel and the first assertion. Did you mean to write 1.1.hours?

Copy link
Contributor

Choose a reason for hiding this comment

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

That's not true. It's "message without expire time never expires".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops! I phrased it wrong

Copy link
Contributor

Choose a reason for hiding this comment

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

We only need one for 100.years — if there's a bug and it hasn't rightly expired by then, it probably didn't expire before hand either.

Copy link
Contributor

Choose a reason for hiding this comment

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

This suggests we should pull out the metadata tests into a shared tests module that we then include in some test case subclasses.

module SharedMessageMetadataTests
  def setup
    @secret    = SecureRandom.random_bytes(32)
    @encryptor = ActiveSupport::MessageEncryptor.new(@secret, encryptor_options)

    super
  end

  def test_xxx
    # …
  end

  # More metadata tests below…
end

class MessageEncryptorMetadataMarshalTest < ActiveSupport::TestCase
  include SharedMessageMetadataTests

  def encryptor_options
    { serializer: Marshal }
  end
end

class MessageEncryptorMetadataJSONTest < ActiveSupport::TestCase
  # All yours…
end

This ensures we'll always have complete compatibility between the two serializers. Consider what would happen if we added a new test for Marshal, but then forgot to add one for JSON as well? 😉

Copy link
Contributor

Choose a reason for hiding this comment

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

👆 for more tips find @jeremy's recent cleanup PR for the Active Support cache store tests.

@kaspth
Copy link
Contributor

kaspth commented Jul 15, 2017

Looks like there's a test failing in the time travel helpers tests: https://travis-ci.org/rails/rails/jobs/253915292#L470

It's likely that your tests aren't cleaning up after themselves properly, so time never gets unstubbed.

Add a teardown :travel_back to fix in your test cases.

@kaspth kaspth mentioned this pull request Jul 15, 2017
Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

Just about there! Needs to use double quoted strings throughout, make the test flow a bit more easier to read still, but that's pretty much it!

Copy link
Contributor

Choose a reason for hiding this comment

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

Slight addendum to the newlines between assertions from yesterday, when there's 2 or more cases like this, it's usually easier to group the act and assertions together:

message = "a security question's answer"

login = @encryptor.encrypt_and_sign(message, purpose: "login")
assert_equal message, @encryptor.decrypt_and_verify(login, purpose: "login")

no_purpose = @encryptor.encrypt_and_sign(message)
assert_equal message, @encryptor.decrypt_and_verify(no_purpose)

(It can also be nice not to reuse the variable name :))

Copy link
Contributor

Choose a reason for hiding this comment

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

Remember to use double quoted strings.

Copy link
Contributor

Choose a reason for hiding this comment

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

Let's also add:

assert_nil @encryptor.decrypt_and_verify(encrypted_message, purpose: "sign up") # Right below here
assert_nil @encryptor.decrypt_and_verify(encrypted_message, purpose: "")

Copy link
Contributor

Choose a reason for hiding this comment

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

Bang on 👍

Copy link
Contributor

Choose a reason for hiding this comment

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

You don't have to get so elaborate with the messages. I'd assign a @message ivar in setup and use that throughout. I'd probably also make @message a hash, so we know the complex scenario encryption scenario works.

Copy link
Contributor

Choose a reason for hiding this comment

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

We should also check that we correctly wrap and unpack arrays too.

Copy link
Contributor

Choose a reason for hiding this comment

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

travel 0.5.seconds?

Copy link
Contributor

Choose a reason for hiding this comment

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

Might be good to add freeze_time above here because this test is so time sensitive.

Copy link
Contributor Author

@assain assain Jul 17, 2017

Choose a reason for hiding this comment

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

I couldn't find any doc on how to use freeze_time 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

😊

# Calls +travel_to+ with +Time.now+.
#
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# freeze_time
# sleep(1)
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
#
# This method also accepts a block, which will return the current time back to its original
# state at the end of the block:
#
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# freeze_time do
# sleep(1)
# User.create.created_at # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# end
# Time.current # => Sun, 09 Jul 2017 15:34:50 EST -05:00
def freeze_time(&block)
travel_to Time.now, &block
end

Copy link
Contributor

Choose a reason for hiding this comment

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

Remember to rebase on master to get it!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I didn't get you! 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, got it! It was connectivity issue.

Copy link
Contributor

Choose a reason for hiding this comment

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

✂️

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

It's rather that expiry supports subsecond precision 😊

Copy link
Contributor

Choose a reason for hiding this comment

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

Remember to call super.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Temporarily, I need some cleaning to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the keys deserialized aren't left as strings wouldn't backward compatibility be affected? (JSON)

Copy link
Contributor

@kaspth kaspth left a comment

Choose a reason for hiding this comment

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

So close now!

Copy link
Contributor

Choose a reason for hiding this comment

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

encrypt_and_sign(message, options)?

Copy link
Contributor

Choose a reason for hiding this comment

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

Same deal. Just pass the options straight along.

Copy link
Contributor

Choose a reason for hiding this comment

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

You're in an ActiveSupport::TestCase now. Let's use setup do.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need the cipher? We've changed the default to be aes-256-gcm, right?

I still think we should go with:

class MessageEncryptorMetadataMarshalTest < MessageEncryptorMetadataTest
  private
    def encryptor_options
      { serializer: Marshal } # Assain, just pass it in.
    end
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When I do instance_variable_get(:@Cipher) I get aes-256-cbc in the tests 😅

Copy link
Contributor

Choose a reason for hiding this comment

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

Use JSON.

Copy link
Contributor

Choose a reason for hiding this comment

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

Call super!

Copy link
Contributor

Choose a reason for hiding this comment

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

@string_message and @array_message are only used in one test. Let's just inline them to that test (remember newlines too).

Copy link
Contributor

Choose a reason for hiding this comment

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

Call super!

Copy link
Contributor

Choose a reason for hiding this comment

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

Man, that parse and generate really cleared the tests up good. Much easier to spot what the tests are about.

Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably put a #:nodoc: here as well, so this module won't show up in docs.

@kaspth kaspth merged commit 1f7f872 into rails:master Jul 19, 2017
@kaspth
Copy link
Contributor

kaspth commented Jul 19, 2017

There! Nice work, @assain!

We've decided to defer documentation and changelog entries until the MessageVerifier has also been taken care of.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants