Add Key Rotation to MessageEncryptor and MessageVerifier and simplify the Cookies middleware#29716
Conversation
857fdde to
c7405d2
Compare
|
Took a brief look at the code, looks nice 👏 One question: the rotator in it's current state seems to be for upgrading cookies and people using MessageEncryptor defaults, which is probably what most people need anyway. But passing in symbols and raising if it doesn't match a known scheme excludes people who use MessageEncryptor now with their own settings. The shorthands for old and new defaults make sense, but it isn't is possible to pass in your own legacy scheme hash that is just passed on to create a new MessageEncryptor. Likewise "only |
kaspth
left a comment
There was a problem hiding this comment.
Made a focused review on how I'd like to bring the idea of key rotation to the fore for users (rather than focusing purely on our need to upgrade their cookies) 😊
Appreciate the work you've been doing so far! You seem really comfortable in the codebase 😄
There was a problem hiding this comment.
I find the scheme wording confusing. Isn't that what aes-256-gcm is? Also dislike the legacy wording since it doesn't clarify how people can use this to rotate their keys.
Ideally I'd like the config to be something like:
# config/initializers/rotations.rb
# Here's some instructions on how to do cookie rotations right and why should be.
# - Rotate your secret key base whenever a coworker leaves, etc.
Rails.application.config.secrets.tap do |secrets|
secrets.rotate_out secret_key_base: secrets.old_secret_key_base
secrets.rotate_out cipher: 'aes-256-gcm'
secrets.rotate_out :signed, digest: 'xxx' # We need some way to explain that a rotation targets either signed or encrypted messages.
end
Rails.application.config.action_dispatch.tap do |config|
config.cookies.rotate_out signed_salt: config.old_signed_salt # It'll use the new config.signed_salt but add this as a fallback.
endWith that API our new framework defaults could then contain:
# config/initializers/new_framework_defaults_5_2.rb
# …
# Use AES 256 GCM authenticated encryption in cookies and ActiveSupport::MessageEncryptor.
# Rails.application.secrets.rotate_out cipher: 'aes-xx-cbc'There was a problem hiding this comment.
I agree with these wording changes.
The word "scheme" is unclear and could refer to several things really. It's probably best to just avoid that term altogether.
Removing the "legacy" wording is a good idea as well since this feature is more than just legacy support. Your configuration above is really nice and clean! I can rework my changes to introduce this style of configuration for this feature.
There was a problem hiding this comment.
@kaspth any thoughts on what I should call a complete configuration for a cipher/digest, the salts, and a secret?
I'm thinking I'm just going to call it an encryptor_config and verifiy_config within the code but am open to something more fitting here!
There was a problem hiding this comment.
How about just verifiers and encryptors? We should support passing a message verifier or encryptor instance to rotate_out, which I forgot to add above.
I'm still on the fence about the _out suffix. Do think you think just config.secrets.rotate would be clear enough to imply that it's the old value that goes there?
There was a problem hiding this comment.
I like the idea of passing an encryptor or verifier directly. This would be for more advanced uses and would be great to support.
Under the hood, I think we can just have these instances be passed to KeyRotator and have the class able to insert arbitrary encryptors or verifiers into its chain. Modifying KeyRotator to support this should somewhat address @bdewater's feedback about making KeyRotator more versatile in general.
I also think the config.secrets.rotate syntax should be clear enough. I feel the "rotating out" concept is implied by the feature as a whole.
|
Thanks @bdewater and @kaspth for the feedback! As far as
I agree with this overall, @bdewater. Opening up Maybe, in general, we can leave the I'm going to rework this class some more such that all potential uses are easily supported in a clear and understandable API. It's far to versatile of a feature to leave for just preset options. Thanks very much for your feedback here!
This comment is actually out-of-date now. Prior to submitting this PR I had limits in place with what could be used as the |
This sounds like a great approach! Better defaults while still allowing a configuration to fully preserve existing behaviour for interoperability 👍 My worry with combining MessageEncryptor with |
|
Also a heads-up: you'll want to add the frozen string magic comment to new files, see #29728 :) |
railties/lib/rails/application.rb
Outdated
There was a problem hiding this comment.
The Railtie component can't use ActionDispatch constants. We should inverse the dependency.
There was a problem hiding this comment.
I had a feeling this was the case. I'm going to rework how the various configuration defaults are set, fix the dependency issues, and remove the top-level CookieSecurity constant.
There was a problem hiding this comment.
This is a normal hash, so how config.action_dispatch["action_dispatch.secret_key_base"] would return something?
There was a problem hiding this comment.
I'm not a fan of this top level constant. This class seems to be private API so maybe move to inside the actionpack/lib/action_dispatch/middleware/cookies.rb file?
There was a problem hiding this comment.
Should not the key rotator wrap the legacy logic automatically? Like, why would someone use the key rotator and use decrypt_and_verify without rescuing the exception and calling decrypt_and_verify_with_legacy_encryptors?
There was a problem hiding this comment.
This is how I originally designed the KeyRotator class. The problem was, in the cookie middleware parse methods, we need to know when the primary encryptor/verifier fails so that the cookie can be updated.
I experimented with having decrypt_and_verify accept an optional block that is called when the primary encryptor/verifier fails. It didn't do the trick though because you end up having to deserialize before doing the actual cookie update.
Definitely open to ideas on how we can have decrypt_and_verify and verify signal to their caller that the primary encryptor or verifier were not used. This would definitely result in a simpler and easier to use API as you have suggested.
There was a problem hiding this comment.
maybe we could invert that. Implement a decrypt_and_verify_without_legacy_encryptors (or just exposing the primary_encryptor for those who want control when the primary encryptor could not be used.
|
I think using both I'm having trouble remembering right now for instance 😅 Perhaps it could be |
Maybe This option will also be clearly documented in that it is used as an actual cipher key and should be sufficiently long, random, and properly encoded. That is, it should probably be a raw byte string from |
c7405d2 to
523987c
Compare
db12569 to
28aef6b
Compare
|
With some great advice from @kaspth, I reworked this PR and added the rotation features directly to the There are still some failing tests with regards to "upgrading" As always, any and all feedback is welcomed! |
4f8726c to
cdb3774
Compare
cdb3774 to
a5db533
Compare
actionpack/lib/action_dispatch.rb
Outdated
There was a problem hiding this comment.
Is this needed? I tried to find where this constant is defined and could not.
There was a problem hiding this comment.
Yikes, this is stale code. I can remove it.
There was a problem hiding this comment.
do/end here. If needed it is better to extract to a local variable than using { to change the precedence.
There was a problem hiding this comment.
It is not clear why you need to use return here. It seems it is because this block is called more than once. So maybe the method should be clear about that.
There was a problem hiding this comment.
The verify method (and the decrypt_and_verify method) call an optional block when a rotated instance is used to verify the message. We need to rewrite the cookie's value when this occurs and this optional block is utilized to signal that.
The tricky part is we need actually write the deserialize message so within this block deserialize is called and the cookie is updated. The return is there so the parse method returns the deserialized value from within the block. If we didn't return here, the outer deserialize would also get invoked and deserialization would effectively occur twice.
I do agree that return here is a little confusing. I'm definitely open to a better way of signal to the caller of verify or decrypt_and_verify when a rotated instance is used so that we can better handle this case.
There was a problem hiding this comment.
This will make all the constants inside it to be not documented.
3fdee6b to
4a64ada
Compare
There was a problem hiding this comment.
Looks like we're always calling this with new.cookies_rotations, then there's no point to the namespace handling in the Configuration class and we should just move up Rotation to RotationConfiguration.
There was a problem hiding this comment.
Should I convert this class to a Singleton? Or should I make rotate as well as the signed and encrypted arrays into class/module methods?
There was a problem hiding this comment.
Nope, let's keep it a regular class. Assigning it to Rails.application.config makes it a "singleton", i.e. accessible across the app.
There was a problem hiding this comment.
These now need to be yanked.
There was a problem hiding this comment.
Let's move this to the alongside the other _salts up top and skip the ||= for =.
There was a problem hiding this comment.
There's a part of me that thinks we should turn:
config.action_dispatch.signed_cookie_salt = "signed cookie"
config.action_dispatch.encrypted_cookie_salt = "encrypted cookie"
config.action_dispatch.encrypted_signed_cookie_salt = "signed encrypted cookie"
config.action_dispatch.use_authenticated_cookie_encryption = falseinto:
config.action_dispatch.cookies = ActiveSupport::OrderedOptions.new
config.action_dispatch.cookies.signed_salt = "signed cookie"
config.action_dispatch.cookies.encrypted_salt = "encrypted cookie"
config.action_dispatch.cookies.encrypted_signed_salt = "signed encrypted cookie"
# These three are old 👆, but these three haven't shipped 👇.
config.action_dispatch.cookies.authenticated_encrypted_salt = "authenticated encrypted cookie"
config.action_dispatch.cookies.authenticated_encryption = false
config.action_dispatch.cookies.rotations = ActiveSupport::Messages::RotationConfiguration.newWe'd need to deprecate the old ones. Though I think it's highly unlikely that many apps have overriden those default salts, so the hurt isn't too huge. Either that or we just reassign for something so trivial.
It's probably out of scope for this, so let me look into that separately.
Though by the way, why do we need the authenticated_encrypted_cookie_salt? We could just as well reuse the encrypted_signed_cookie_salt?
There was a problem hiding this comment.
When I added AEAD support I wanted to use a different salt value as this is a best practice when deriving keys for different uses.
|
Yup, let's move forward with that configuration API :) |
Good call on this. I can update those calls and even add a test case for it. |
Nice to "centralized" method for validating the configuration! |
There was a problem hiding this comment.
constructor reads like a JavaScript word, I'd say the same arguments as +new+.
There was a problem hiding this comment.
Had a feeling it would be. I can just remove this and require the class as needed.
|
Looks like the documentation update is missing and then we need to do some commit squashing. Perhaps the Active Support changes and then the cookie changes are two separate commits? Unless you can spot a division that's more git history apt 😊 |
|
@kaspth yup, the docs is next up for me. Sounds good on the squash commit. That division makes the most sense to me. |
|
Thought some more about the Additionally, allowing both encrypted and signed cookies to have the same configuration would lead to the same salt and thus the same key being used for different security features which is not a good idea security-wise. Thoughts @kaspth? Should be easy to remove this small part of the class. Edit: It's probably OK to define both at the same time if just a |
|
Updated the guides and the documentation some more. I hope it's OK that I reworked the session cookie section of the Security guide a bit. Just felt odd at this point to have that section lead with legacy details. I'd rather explain security for the current version upfront then explain differences in prior versions after. Want to just review it again with fresh eyes and then squash the commits. |
|
Let's use the I'm going to be out of the country the week after this. So this has to be wrapped up soon. If you can get to the squashing tomorrow then great. Otherwise I'll merge and do some follow up codewise then 😊 |
|
Now I'm actually a bit confused on how For Rails 5.2 this value will default to I wrote a test case for |
Both classes now have a rotate method where new instances are added for each call. When decryption or verification fails the next rotation instance is tried.
d76f4be to
39de46a
Compare
|
Squashed the commits. I think I broke something with the |
Using the action_dispatch.cookies_rotations interface, key rotation is now possible with cookies. Thus the secret_key_base as well as salts, ciphers, and digests, can be rotated without expiring sessions.
39de46a to
8b0af54
Compare
|
Great! The build looked fine and green to me 😄👏 |
|
One last thing, @kaspth: what about having the cookies middleware use CBC encryption when |
|
What's it doing currently? I think |
|
Currently it's just responsible for enabling the upgrade behavior, which rotates CBC to GCM. I feel setting it to diff actionpack/lib/
diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb
index b383164..dbe46be 100644
--- a/actionpack/lib/action_dispatch/middleware/cookies.rb
+++ b/actionpack/lib/action_dispatch/middleware/cookies.rb
@@ -599,9 +599,16 @@ class EncryptedKeyRotatingCookieJar < AbstractCookieJar # :nodoc:
def initialize(parent_jar)
super
- key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher)
- secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len)
- @encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: SERIALIZER)
+ if request.use_authenticated_cookie_encryption
+ key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher)
+ secret = request.key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len)
+ @encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: SERIALIZER)
+ else
+ key_len = ActiveSupport::MessageEncryptor.key_len("aes-256-cbc")
+ secret = request.key_generator.generate_key(request.encrypted_cookie_salt, key_len)
+ sign_secret = request.key_generator.generate_key(request.encrypted_signed_cookie_salt)
+ @encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", serializer: SERIALIZER)
+ end
request.cookies_rotations.encrypted.each do |rotation_options|
@encryptor.rotate serializer: SERIALIZER, **rotation_optionsEdit: made a branch and can submit a PR for it: https://github.com/rails/rails/compare/master...mikeycgto:actiondispatch-use-aead-encrypted-cookies-patch?expand=1 |
|
Had a bit of a change of heart on the rotate API as I realized that neither the verifier or encryptor ever mentions key generators or salts. They're only meant to work with generated secrets, so I didn't like to add the extra support there after all. Think we'll have to go through The cookies middleware then felt a little strange, so I chose to nix the |
|
Sounds good @kaspth. The changes in the documentation and Let me know if I should submit my |
Summary
This PR introduces
ActiveSupport::KeyRotatorwhich wrapsKeyGeneratoras well asMessageEncryptorandMessageVerifierand provides an interface for easily rotating between different encryption ciphers or message digests, salts, and secrets.This PR also simplifies the cookies middleware and removes several of the internal legacy middlewares. Upgrading both signed and encrypted legacy cookies is now handled by rotation capable middlewares which each use a
KeyRotatorinstance. Is also introduces a new interface for configuring the middleware.Other Information
This feature spawned from discussions had in the Rails’ basecamp.
I still need to update the configuring and security markdown guides with details of this change. I plan on adding a new section the security guide detailing how secrets and salts can be rotated. I also want to add more rotation tests to
railties/test/application/middleware/cookies_test.rb.Any and all feedback is welcomed!
/cc @matthewd @kaspth