Skip to content

Commit cd762a8

Browse files
Merge pull request rails#46756 from jonathanhefner/messages-rotation_coordinator-rotate-block
Support `Message{Encryptors,Verifiers}#rotate` block
2 parents f15e576 + 61f711a commit cd762a8

File tree

6 files changed

+222
-21
lines changed

6 files changed

+222
-21
lines changed

activesupport/lib/active_support/message_encryptors.rb

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ class MessageEncryptors < Messages::RotationCoordinator
6060

6161
##
6262
# :method: rotate
63-
# :call-seq: rotate(**options)
63+
# :call-seq:
64+
# rotate(**options)
65+
# rotate(&block)
6466
#
6567
# Adds +options+ to the list of option sets. Messages will be encrypted
6668
# using the first set in the list. When decrypting, however, each set will
@@ -75,6 +77,35 @@ class MessageEncryptors < Messages::RotationCoordinator
7577
# If any options match the kwargs of the operative secret generator, those
7678
# options will be passed to the secret generator instead of to the message
7779
# encryptor.
80+
#
81+
# For fine-grained per-salt rotations, a block form is supported. The block
82+
# will receive the salt, and should return an appropriate options Hash. The
83+
# block may also return +nil+ to indicate that the rotation does not apply
84+
# to the given salt. For example:
85+
#
86+
# encryptors = ActiveSupport::MessageEncryptors.new { ... }
87+
#
88+
# encryptors.rotate do |salt|
89+
# case salt
90+
# when :foo
91+
# { serializer: JSON, url_safe: true }
92+
# when :bar
93+
# { serializer: Marshal, url_safe: true }
94+
# end
95+
# end
96+
#
97+
# encryptors.rotate(serializer: Marshal, url_safe: false)
98+
#
99+
# # Uses `serializer: JSON, url_safe: true`.
100+
# # Falls back to `serializer: Marshal, url_safe: false`.
101+
# encryptors[:foo]
102+
#
103+
# # Uses `serializer: Marshal, url_safe: true`.
104+
# # Falls back to `serializer: Marshal, url_safe: false`.
105+
# encryptors[:bar]
106+
#
107+
# # Uses `serializer: Marshal, url_safe: false`.
108+
# encryptors[:baz]
78109

79110
##
80111
# :method: rotate_defaults

activesupport/lib/active_support/message_verifiers.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,35 @@ class MessageVerifiers < Messages::RotationCoordinator
7373
# If any options match the kwargs of the operative secret generator, those
7474
# options will be passed to the secret generator instead of to the message
7575
# verifier.
76+
#
77+
# For fine-grained per-salt rotations, a block form is supported. The block
78+
# will receive the salt, and should return an appropriate options Hash. The
79+
# block may also return +nil+ to indicate that the rotation does not apply
80+
# to the given salt. For example:
81+
#
82+
# verifiers = ActiveSupport::MessageVerifiers.new { ... }
83+
#
84+
# verifiers.rotate do |salt|
85+
# case salt
86+
# when :foo
87+
# { serializer: JSON, url_safe: true }
88+
# when :bar
89+
# { serializer: Marshal, url_safe: true }
90+
# end
91+
# end
92+
#
93+
# verifiers.rotate(serializer: Marshal, url_safe: false)
94+
#
95+
# # Uses `serializer: JSON, url_safe: true`.
96+
# # Falls back to `serializer: Marshal, url_safe: false`.
97+
# verifiers[:foo]
98+
#
99+
# # Uses `serializer: Marshal, url_safe: true`.
100+
# # Falls back to `serializer: Marshal, url_safe: false`.
101+
# verifiers[:bar]
102+
#
103+
# # Uses `serializer: Marshal, url_safe: false`.
104+
# verifiers[:baz]
76105

77106
##
78107
# :method: rotate_defaults

activesupport/lib/active_support/messages/rotation_coordinator.rb

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,18 @@ def initialize(&secret_generator)
1616
end
1717

1818
def [](salt)
19-
@codecs[salt] ||= build_with_rotations(salt.to_s)
19+
@codecs[salt] ||= build_with_rotations(salt)
2020
end
2121

2222
def []=(salt, codec)
2323
@codecs[salt] = codec
2424
end
2525

26-
def rotate(**options)
26+
def rotate(**options, &block)
27+
raise ArgumentError, "Options cannot be specified when using a block" if block && !options.empty?
2728
changing_configuration!
2829

29-
options[:secret_generator] ||= @secret_generator
30-
secret_generator_kwargs = options[:secret_generator].parameters.
31-
filter_map { |type, name| name if type == :key || type == :keyreq }
32-
options[:secret_generator_options] = options.extract!(*secret_generator_kwargs)
33-
34-
@rotate_options << options
30+
@rotate_options << (block || options)
3531

3632
self
3733
end
@@ -63,16 +59,30 @@ def changing_configuration!
6359
end
6460
end
6561

62+
def normalize_options(options)
63+
options = options.dup
64+
65+
options[:secret_generator] ||= @secret_generator
66+
67+
secret_generator_kwargs = options[:secret_generator].parameters.
68+
filter_map { |type, name| name if type == :key || type == :keyreq }
69+
options[:secret_generator_options] = options.extract!(*secret_generator_kwargs)
70+
71+
options[:on_rotation] = @on_rotation
72+
73+
options
74+
end
75+
6676
def build_with_rotations(salt)
67-
raise "No options have been configured" if @rotate_options.empty?
77+
rotate_options = @rotate_options.map { |options| options.is_a?(Proc) ? options.(salt) : options }
78+
transitional = self.transitional && rotate_options.first
79+
rotate_options.compact!
80+
rotate_options[0..1] = rotate_options[0..1].reverse if transitional
81+
rotate_options = rotate_options.map { |options| normalize_options(options) }.uniq
6882

69-
if transitional
70-
rotate_options = [@rotate_options[1], @rotate_options[0], *@rotate_options[2..]].compact
71-
else
72-
rotate_options = @rotate_options
73-
end
83+
raise "No options have been configured for #{salt}" if rotate_options.empty?
7484

75-
rotate_options.map { |options| build(salt, **options, on_rotation: @on_rotation) }.reduce(&:fall_back_to)
85+
rotate_options.map { |options| build(salt.to_s, **options) }.reduce(&:fall_back_to)
7686
end
7787

7888
def build(salt, secret_generator:, secret_generator_options:, **options)

activesupport/test/message_encryptors_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ class MessageEncryptorsTest < ActiveSupport::TestCase
2222
assert_equal "message", roundtrip("message", coordinator["salt"])
2323
end
2424

25+
test "supports arbitrary secret generator kwargs when using #rotate block" do
26+
secret_generator = ->(salt, secret_length:, foo:, bar: nil) { foo[bar] * secret_length }
27+
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)
28+
coordinator.rotate { { foo: "foo", bar: 0 } }
29+
30+
assert_equal "message", roundtrip("message", coordinator["salt"])
31+
end
32+
2533
test "supports separate secrets for encryption and signing" do
2634
secret_generator = proc { |*args, **kwargs| [SECRET_GENERATOR.call(*args, **kwargs), "signing secret"] }
2735
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)

activesupport/test/message_verifiers_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ class MessageVerifiersTest < ActiveSupport::TestCase
2222
assert_equal "message", roundtrip("message", coordinator["salt"])
2323
end
2424

25+
test "supports arbitrary secret generator kwargs when using #rotate block" do
26+
secret_generator = ->(salt, foo:, bar: nil) { foo + bar }
27+
coordinator = ActiveSupport::MessageVerifiers.new(&secret_generator)
28+
coordinator.rotate { { foo: "foo", bar: "bar" } }
29+
30+
assert_equal "message", roundtrip("message", coordinator["salt"])
31+
end
32+
2533
private
2634
def make_coordinator
2735
ActiveSupport::MessageVerifiers.new { |salt| salt * 10 }

activesupport/test/rotation_coordinator_tests.rb

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,66 @@ module RotationCoordinatorTests
3434
assert_nil roundtrip("message", codec, obsolete_codec)
3535
end
3636

37+
test "raises when building a codec and no rotations are configured" do
38+
assert_raises { make_coordinator["salt"] }
39+
end
40+
41+
test "#rotate supports a block" do
42+
coordinator = make_coordinator.rotate do |salt|
43+
{ digest: salt == "salt" ? "SHA1" : "MD5" }
44+
end
45+
46+
sha1_coordinator = make_coordinator.rotate(digest: "SHA1")
47+
md5_coordinator = make_coordinator.rotate(digest: "MD5")
48+
49+
assert_equal "message", roundtrip("message", coordinator["salt"], sha1_coordinator["salt"])
50+
assert_nil roundtrip("message", coordinator["salt"], md5_coordinator["salt"])
51+
52+
assert_equal "message", roundtrip("message", coordinator["other salt"], md5_coordinator["other salt"])
53+
assert_nil roundtrip("message", coordinator["other salt"], sha1_coordinator["other salt"])
54+
end
55+
56+
test "#rotate block receives salt in its original form" do
57+
coordinator = make_coordinator.rotate do |salt|
58+
assert_equal :salt, salt
59+
{}
60+
end
61+
62+
coordinator[:salt]
63+
end
64+
65+
test "#rotate raises when both a block and options are provided" do
66+
assert_raises ArgumentError do
67+
make_coordinator.rotate(digest: "MD5") { {} }
68+
end
69+
end
70+
71+
test "#rotate block can return nil to skip a rotation for specific salts" do
72+
coordinator = make_coordinator.rotate(digest: "SHA1")
73+
coordinator.rotate do |salt|
74+
{ digest: "MD5" } if salt == "salt"
75+
end
76+
77+
sha1_coordinator = make_coordinator.rotate(digest: "SHA1")
78+
md5_coordinator = make_coordinator.rotate(digest: "MD5")
79+
80+
assert_equal "message", roundtrip("message", sha1_coordinator["salt"], coordinator["salt"])
81+
assert_equal "message", roundtrip("message", md5_coordinator["salt"], coordinator["salt"])
82+
83+
assert_equal "message", roundtrip("message", sha1_coordinator["other salt"], coordinator["other salt"])
84+
assert_nil roundtrip("message", md5_coordinator["other salt"], coordinator["other salt"])
85+
end
86+
87+
test "raises when building a codec and no rotations are configured for a specific salt" do
88+
coordinator = make_coordinator.rotate do |salt|
89+
{ digest: "MD5" } if salt == "salt"
90+
end
91+
92+
assert_nothing_raised { coordinator["salt"] }
93+
error = assert_raises { coordinator["other salt"] }
94+
assert_match "other salt", error.message
95+
end
96+
3797
test "#transitional swaps the first two rotations when enabled" do
3898
coordinator = make_coordinator.rotate(digest: "SHA1")
3999
coordinator.rotate(digest: "MD5")
@@ -65,6 +125,48 @@ module RotationCoordinatorTests
65125
end
66126
end
67127

128+
test "#transitional treats a nil first rotation as a new rotation" do
129+
coordinator = make_coordinator
130+
coordinator.rotate do |salt| # (3) Finally, one salt upgraded to SHA1
131+
{ digest: "SHA1" } if salt == "salt"
132+
end
133+
coordinator.rotate(digest: "MD5") # (2) Then, everything upgraded to MD5
134+
coordinator.rotate(digest: "MD4") # (1) Originally, everything used MD4
135+
coordinator.transitional = true
136+
137+
sha1_coordinator = make_coordinator.rotate(digest: "SHA1")
138+
md5_coordinator = make_coordinator.rotate(digest: "MD5")
139+
140+
# "salt" encodes with MD5 and can decode SHA1 (i.e. [SHA1, MD5, MD4] => [MD5, SHA1, MD4])
141+
assert_equal "message", roundtrip("message", coordinator["salt"], md5_coordinator["salt"])
142+
assert_equal "message", roundtrip("message", sha1_coordinator["salt"], coordinator["salt"])
143+
144+
# "other salt" encodes with MD5 and cannot decode SHA1 (i.e. [nil, MD5, MD4] => [MD5, MD4])
145+
assert_equal "message", roundtrip("message", coordinator["other salt"], md5_coordinator["other salt"])
146+
assert_nil roundtrip("message", sha1_coordinator["other salt"], coordinator["other salt"])
147+
end
148+
149+
test "#transitional swaps the first rotation with the next non-nil rotation" do
150+
coordinator = make_coordinator
151+
coordinator.rotate(digest: "SHA1") # (3) Finally, everything upgraded to SHA1
152+
coordinator.rotate do |salt| # (2) Then, one salt upgraded to SHA1
153+
{ digest: "SHA1" } if salt == "salt"
154+
end
155+
coordinator.rotate(digest: "MD5") # (1) Originally, everything used MD5
156+
coordinator.transitional = true
157+
158+
sha1_coordinator = make_coordinator.rotate(digest: "SHA1")
159+
md5_coordinator = make_coordinator.rotate(digest: "MD5")
160+
161+
# "salt" encodes with SHA1 and can decode SHA1 (i.e. [SHA1, SHA1, MD5] => [SHA1, MD5])
162+
assert_equal "message", roundtrip("message", coordinator["salt"], sha1_coordinator["salt"])
163+
assert_equal "message", roundtrip("message", sha1_coordinator["salt"], coordinator["salt"])
164+
165+
# "other salt" encodes with MD5 and can decode SHA1 (i.e. [SHA1, nil, MD5] => [MD5, SHA1])
166+
assert_equal "message", roundtrip("message", coordinator["other salt"], md5_coordinator["other salt"])
167+
assert_equal "message", roundtrip("message", sha1_coordinator["other salt"], coordinator["other salt"])
168+
end
169+
68170
test "can clear rotations" do
69171
@coordinator.clear_rotations.rotate(digest: "MD5")
70172
codec = @coordinator["salt"]
@@ -84,6 +186,24 @@ module RotationCoordinatorTests
84186
assert_equal 1, rotated
85187
end
86188

189+
test "rotation options are deduped" do
190+
coordinator = make_coordinator
191+
coordinator.rotate(digest: "SHA1") # (3) Finally, everything upgraded to SHA1
192+
coordinator.rotate do |salt| # (2) Then, one salt upgraded to SHA1
193+
{ digest: "SHA1" } if salt == "salt"
194+
end
195+
coordinator.rotate(digest: "MD5") # (1) Originally, everything used MD5
196+
197+
rotated = 0
198+
coordinator.on_rotation { rotated += 1 }
199+
200+
codec = coordinator["salt"]
201+
md5_codec = (make_coordinator.rotate(digest: "MD5"))["salt"]
202+
203+
assert_equal "message", roundtrip("message", md5_codec, codec)
204+
assert_equal 1, rotated # SHA1 tried only once
205+
end
206+
87207
test "prevents adding a rotation after rotations have been applied" do
88208
@coordinator["salt"]
89209
assert_raises { @coordinator.rotate(digest: "MD5") }
@@ -98,10 +218,5 @@ module RotationCoordinatorTests
98218
@coordinator["salt"]
99219
assert_raises { @coordinator.on_rotation { "this block will not be evaluated" } }
100220
end
101-
102-
test "raises when building an codec and no rotations are configured" do
103-
@coordinator.clear_rotations
104-
assert_raises { @coordinator["salt"] }
105-
end
106221
end
107222
end

0 commit comments

Comments
 (0)