Skip to content

Commit

Permalink
Merge pull request rails#46755 from jonathanhefner/messages-rotation_…
Browse files Browse the repository at this point in the history
…coordinator-transitional

Add `Message{Encryptors,Verifiers}#transitional`
  • Loading branch information
jonathanhefner committed Dec 19, 2022
2 parents 2d30ecd + 38a056c commit f15e576
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 1 deletion.
23 changes: 23 additions & 0 deletions activesupport/lib/active_support/message_encryptors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@

module ActiveSupport
class MessageEncryptors < Messages::RotationCoordinator
##
# :attr_accessor: transitional
#
# If true, the first two rotation option sets are swapped when building
# message encryptors. For example, with the following configuration, message
# encryptors will encrypt messages using <tt>serializer: Marshal, url_safe: true</tt>,
# and will able to decrypt messages that were encrypted using any of the
# three option sets:
#
# encryptors = ActiveSupport::MessageEncryptors.new { ... }
# encryptors.rotate(serializer: JSON, url_safe: true)
# encryptors.rotate(serializer: Marshal, url_safe: true)
# encryptors.rotate(serializer: Marshal, url_safe: false)
# encryptors.transitional = true
#
# This can be useful when performing a rolling deploy of an application,
# wherein servers that have not yet been updated must still be able to
# decrypt messages from updated servers. In such a scenario, first perform a
# rolling deploy with the new rotation (e.g. <tt>serializer: JSON, url_safe: true</tt>)
# as the first rotation and <tt>transitional = true</tt>. Then, after all
# servers have been updated, perform a second rolling deploy with
# <tt>transitional = false</tt>.

##
# :method: initialize
# :call-seq: initialize(&secret_generator)
Expand Down
23 changes: 23 additions & 0 deletions activesupport/lib/active_support/message_verifiers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@

module ActiveSupport
class MessageVerifiers < Messages::RotationCoordinator
##
# :attr_accessor: transitional
#
# If true, the first two rotation option sets are swapped when building
# message verifiers. For example, with the following configuration, message
# verifiers will generate messages using <tt>serializer: Marshal, url_safe: true</tt>,
# and will able to verify messages that were generated using any of the
# three option sets:
#
# verifiers = ActiveSupport::MessageVerifiers.new { ... }
# verifiers.rotate(serializer: JSON, url_safe: true)
# verifiers.rotate(serializer: Marshal, url_safe: true)
# verifiers.rotate(serializer: Marshal, url_safe: false)
# verifiers.transitional = true
#
# This can be useful when performing a rolling deploy of an application,
# wherein servers that have not yet been updated must still be able to
# verify messages from updated servers. In such a scenario, first perform a
# rolling deploy with the new rotation (e.g. <tt>serializer: JSON, url_safe: true</tt>)
# as the first rotation and <tt>transitional = true</tt>. Then, after all
# servers have been updated, perform a second rolling deploy with
# <tt>transitional = false</tt>.

##
# :method: initialize
# :call-seq: initialize(&secret_generator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
module ActiveSupport
module Messages
class RotationCoordinator # :nodoc:
attr_accessor :transitional

def initialize(&secret_generator)
raise ArgumentError, "A secret generator block is required" unless secret_generator
@secret_generator = secret_generator
Expand Down Expand Up @@ -63,7 +65,14 @@ def changing_configuration!

def build_with_rotations(salt)
raise "No options have been configured" if @rotate_options.empty?
@rotate_options.map { |options| build(salt, **options, on_rotation: @on_rotation) }.reduce(&:fall_back_to)

if transitional
rotate_options = [@rotate_options[1], @rotate_options[0], *@rotate_options[2..]].compact
else
rotate_options = @rotate_options
end

rotate_options.map { |options| build(salt, **options, on_rotation: @on_rotation) }.reduce(&:fall_back_to)
end

def build(salt, secret_generator:, secret_generator_options:, **options)
Expand Down
31 changes: 31 additions & 0 deletions activesupport/test/rotation_coordinator_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,37 @@ module RotationCoordinatorTests
assert_nil roundtrip("message", codec, obsolete_codec)
end

test "#transitional swaps the first two rotations when enabled" do
coordinator = make_coordinator.rotate(digest: "SHA1")
coordinator.rotate(digest: "MD5")
coordinator.rotate(digest: "MD4")
coordinator.transitional = true

codec = coordinator["salt"]
sha1_codec = (make_coordinator.rotate(digest: "SHA1"))["salt"]
md5_codec = (make_coordinator.rotate(digest: "MD5"))["salt"]
md4_codec = (make_coordinator.rotate(digest: "MD4"))["salt"]

assert_equal "message", roundtrip("message", codec, md5_codec)
assert_nil roundtrip("message", codec, sha1_codec)

assert_equal "message", roundtrip("message", sha1_codec, codec)
assert_equal "message", roundtrip("message", md5_codec, codec)
assert_equal "message", roundtrip("message", md4_codec, codec)
end

test "#transitional works with a single rotation" do
@coordinator.transitional = true

assert_nothing_raised do
codec = @coordinator["salt"]
assert_equal "message", roundtrip("message", codec)

different_codec = (make_coordinator.rotate(digest: "MD5"))["salt"]
assert_nil roundtrip("message", different_codec, codec)
end
end

test "can clear rotations" do
@coordinator.clear_rotations.rotate(digest: "MD5")
codec = @coordinator["salt"]
Expand Down

0 comments on commit f15e576

Please sign in to comment.