Add purpose and expiry to messages encrypted using Message Encryptor#29599
Add purpose and expiry to messages encrypted using Message Encryptor#29599kaspth merged 1 commit intorails:masterfrom assain:add_meta_data_to_message_encryptor
Conversation
|
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 😊
kaspth
left a comment
There was a problem hiding this comment.
Here's an initial review. Looks like we have a lot of the pieces in place already 👏
There was a problem hiding this comment.
Needs to be a keyword arg: def decrypt_and_verify(value, purpose: nil)
There was a problem hiding this comment.
_decrypt deals with the raw decryption process. I think the extra verifications should be in decrypt_and_verify
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
endWe'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.
There was a problem hiding this comment.
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).
kaspth
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
endThere was a problem hiding this comment.
Delete the newline after private.
There was a problem hiding this comment.
Again, double ternaries are extremely tough for others to read. That cost isn't worth the terseness, so just use a standard if … elsif.
There was a problem hiding this comment.
self here is Metadata so we can just say new instead of Metadata.new.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Ruby has deprecated has_key? in favor of key?.
There was a problem hiding this comment.
I think == is the more apt method to compare with here.
There was a problem hiding this comment.
Use assert_nil to spare us an exception on the eventual Minitest 6.
There was a problem hiding this comment.
We're already in the ActiveSupport namespace here. So I think we can shorten this to Messages::Metadata. Same above.
kaspth
left a comment
There was a problem hiding this comment.
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 😊
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Lets just inline this message variable.
There was a problem hiding this comment.
Lets rename value to data — clarifies that this decrypts raw encrypted data.
There was a problem hiding this comment.
Ruby returns nil by default, so skip the else branch.
There was a problem hiding this comment.
Newline above here as well.
There was a problem hiding this comment.
Newline above here, before the other travel and the first assertion. Did you mean to write 1.1.hours?
There was a problem hiding this comment.
That's not true. It's "message without expire time never expires".
There was a problem hiding this comment.
Oops! I phrased it wrong
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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…
endThis 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? 😉
There was a problem hiding this comment.
👆 for more tips find @jeremy's recent cleanup PR for the Active Support cache store tests.
|
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 |
kaspth
left a comment
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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 :))
There was a problem hiding this comment.
Remember to use double quoted strings.
There was a problem hiding this comment.
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: "")There was a problem hiding this comment.
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.
There was a problem hiding this comment.
We should also check that we correctly wrap and unpack arrays too.
There was a problem hiding this comment.
Might be good to add freeze_time above here because this test is so time sensitive.
There was a problem hiding this comment.
I couldn't find any doc on how to use freeze_time 😅
There was a problem hiding this comment.
😊
rails/activesupport/lib/active_support/testing/time_helpers.rb
Lines 165 to 183 in fbade51
There was a problem hiding this comment.
Remember to rebase on master to get it!
There was a problem hiding this comment.
Sorry, I didn't get you! 😅
There was a problem hiding this comment.
Yeah, got it! It was connectivity issue.
There was a problem hiding this comment.
Let's say MessageEncryptorTest::JSONSerializer.new here. https://github.com/assain/rails/blob/288b3d60b3b46d636425f3ba2c712cf0103f6870/activesupport/test/message_encryptor_test.rb#L9
There was a problem hiding this comment.
It's rather that expiry supports subsecond precision 😊
There was a problem hiding this comment.
Temporarily, I need some cleaning to do.
There was a problem hiding this comment.
If the keys deserialized aren't left as strings wouldn't backward compatibility be affected? (JSON)
There was a problem hiding this comment.
encrypt_and_sign(message, options)?
There was a problem hiding this comment.
Same deal. Just pass the options straight along.
There was a problem hiding this comment.
You're in an ActiveSupport::TestCase now. Let's use setup do.
There was a problem hiding this comment.
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
endThere was a problem hiding this comment.
When I do instance_variable_get(:@Cipher) I get aes-256-cbc in the tests 😅
There was a problem hiding this comment.
@string_message and @array_message are only used in one test. Let's just inline them to that test (remember newlines too).
There was a problem hiding this comment.
Man, that parse and generate really cleared the tests up good. Much easier to spot what the tests are about.
There was a problem hiding this comment.
We should probably put a #:nodoc: here as well, so this module won't show up in docs.
|
There! Nice work, @assain! We've decided to defer documentation and changelog entries until the MessageVerifier has also been taken care of. |
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.