Skip to content

Commit f05b6dd

Browse files
Merge pull request rails#44179 from jonathanhefner/add-message_verifiers-message_encryptors
Add `MessageVerifiers` and `MessageEncryptors` classes
2 parents 531d4cd + baade55 commit f05b6dd

File tree

12 files changed

+504
-24
lines changed

12 files changed

+504
-24
lines changed

activesupport/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
* Add `Rails.application.message_verifiers` as a central point to configure
2+
and create message verifiers for an application.
3+
4+
This allows applications to, for example, rotate old `secret_key_base`
5+
values:
6+
7+
```ruby
8+
config.before_initialize do |app|
9+
app.message_verifiers.rotate(secret_key_base: "old secret_key_base")
10+
end
11+
```
12+
13+
And for libraries to create preconfigured message verifiers:
14+
15+
```ruby
16+
ActiveStorage.verifier = Rails.application.message_verifiers["ActiveStorage"]
17+
```
18+
19+
*Jonathan Hefner*
20+
121
* Add `assert_error_reported` and `assert_no_error_reported`
222

323
Allows to easily asserts an error happened but was handled

activesupport/lib/active_support.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ module ActiveSupport
6969
autoload :JsonWithMarshalFallback
7070
autoload :KeyGenerator
7171
autoload :MessageEncryptor
72+
autoload :MessageEncryptors
7273
autoload :MessageVerifier
74+
autoload :MessageVerifiers
7375
autoload :Multibyte
7476
autoload :NumberHelper
7577
autoload :OptionMerger
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/messages/rotation_coordinator"
4+
5+
module ActiveSupport
6+
class MessageEncryptors < Messages::RotationCoordinator
7+
##
8+
# :method: initialize
9+
# :call-seq: initialize(&secret_generator)
10+
#
11+
# Initializes a new instance. +secret_generator+ must accept a salt and a
12+
# +secret_length+ kwarg, and return a suitable secret (string) or secrets
13+
# (array of strings). +secret_generator+ may also accept other arbitrary
14+
# kwargs. If #rotate is called with any options matching those kwargs, those
15+
# options will be passed to +secret_generator+ instead of to the message
16+
# encryptor.
17+
#
18+
# encryptors = ActiveSupport::MessageEncryptors.new do |salt, secret_length:, base:|
19+
# MySecretGenerator.new(base).generate(salt, secret_length)
20+
# end
21+
#
22+
# encryptors.rotate(base: "...")
23+
24+
##
25+
# :method: []
26+
# :call-seq: [](salt)
27+
#
28+
# Returns a MessageEncryptor configured with a secret derived from the
29+
# given +salt+, and options from #rotate. MessageEncryptor instances will
30+
# be memoized, so the same +salt+ will return the same instance.
31+
32+
##
33+
# :method: []=
34+
# :call-seq: []=(salt, encryptor)
35+
#
36+
# Overrides a MessageEncryptor instance associated with a given +salt+.
37+
38+
##
39+
# :method: rotate
40+
# :call-seq: rotate(**options)
41+
#
42+
# Adds +options+ to the list of option sets. Messages will be encrypted
43+
# using the first set in the list. When decrypting, however, each set will
44+
# be tried, in order, until one succeeds.
45+
#
46+
# Notably, the +:secret_generator+ option can specify a different secret
47+
# generator than the one initially specified. The secret generator must
48+
# respond to +call+, accept a salt and a +secret_length+ kwarg, and return
49+
# a suitable secret (string) or secrets (array of strings). The secret
50+
# generator may also accept other arbitrary kwargs.
51+
#
52+
# If any options match the kwargs of the operative secret generator, those
53+
# options will be passed to the secret generator instead of to the message
54+
# encryptor.
55+
56+
##
57+
# :method: rotate_defaults
58+
# :call-seq: rotate_defaults
59+
#
60+
# Invokes #rotate with the default options.
61+
62+
##
63+
# :method: clear_rotations
64+
# :call-seq: clear_rotations
65+
#
66+
# Clears the list of option sets.
67+
68+
##
69+
# :method: on_rotation
70+
# :call-seq: on_rotation(&callback)
71+
#
72+
# Sets a callback to invoke when a message is decrypted using an option set
73+
# other than the first.
74+
#
75+
# For example, this callback could log each time it is called, and thus
76+
# indicate whether old option sets are still in use or can be removed from
77+
# rotation.
78+
79+
private
80+
def build(salt, secret_generator:, secret_generator_options:, **options)
81+
secret_length = MessageEncryptor.key_len(*options[:cipher])
82+
secret = secret_generator.call(salt, secret_length: secret_length, **secret_generator_options)
83+
MessageEncryptor.new(*Array(secret), **options)
84+
end
85+
end
86+
end
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/messages/rotation_coordinator"
4+
5+
module ActiveSupport
6+
class MessageVerifiers < Messages::RotationCoordinator
7+
##
8+
# :method: initialize
9+
# :call-seq: initialize(&secret_generator)
10+
#
11+
# Initializes a new instance. +secret_generator+ must accept a salt, and
12+
# return a suitable secret (string). +secret_generator+ may also accept
13+
# arbitrary kwargs. If #rotate is called with any options matching those
14+
# kwargs, those options will be passed to +secret_generator+ instead of to
15+
# the message verifier.
16+
#
17+
# verifiers = ActiveSupport::MessageVerifiers.new do |salt, base:|
18+
# MySecretGenerator.new(base).generate(salt)
19+
# end
20+
#
21+
# verifiers.rotate(base: "...")
22+
23+
##
24+
# :method: []
25+
# :call-seq: [](salt)
26+
#
27+
# Returns a MessageVerifier configured with a secret derived from the
28+
# given +salt+, and options from #rotate. MessageVerifier instances will
29+
# be memoized, so the same +salt+ will return the same instance.
30+
31+
##
32+
# :method: []=
33+
# :call-seq: []=(salt, verifier)
34+
#
35+
# Overrides a MessageVerifier instance associated with a given +salt+.
36+
37+
##
38+
# :method: rotate
39+
# :call-seq: rotate(**options)
40+
#
41+
# Adds +options+ to the list of option sets. Messages will be signed using
42+
# the first set in the list. When verifying, however, each set will be
43+
# tried, in order, until one succeeds.
44+
#
45+
# Notably, the +:secret_generator+ option can specify a different secret
46+
# generator than the one initially specified. The secret generator must
47+
# respond to +call+, accept a salt, and return a suitable secret (string).
48+
# The secret generator may also accept arbitrary kwargs.
49+
#
50+
# If any options match the kwargs of the operative secret generator, those
51+
# options will be passed to the secret generator instead of to the message
52+
# verifier.
53+
54+
##
55+
# :method: rotate_defaults
56+
# :call-seq: rotate_defaults
57+
#
58+
# Invokes #rotate with the default options.
59+
60+
##
61+
# :method: clear_rotations
62+
# :call-seq: clear_rotations
63+
#
64+
# Clears the list of option sets.
65+
66+
##
67+
# :method: on_rotation
68+
# :call-seq: on_rotation(&callback)
69+
#
70+
# Sets a callback to invoke when a message is verified using an option set
71+
# other than the first.
72+
#
73+
# For example, this callback could log each time it is called, and thus
74+
# indicate whether old option sets are still in use or can be removed from
75+
# rotation.
76+
77+
private
78+
def build(salt, secret_generator:, secret_generator_options:, **options)
79+
MessageVerifier.new(secret_generator.call(salt, **secret_generator_options), **options)
80+
end
81+
end
82+
end
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/core_ext/hash/slice"
4+
5+
module ActiveSupport
6+
module Messages
7+
class RotationCoordinator # :nodoc:
8+
def initialize(&secret_generator)
9+
raise ArgumentError, "A secret generator block is required" unless secret_generator
10+
@secret_generator = secret_generator
11+
@rotate_options = []
12+
@codecs = {}
13+
end
14+
15+
def [](salt)
16+
@codecs[salt] ||= build_with_rotations(salt.to_s)
17+
end
18+
19+
def []=(salt, codec)
20+
@codecs[salt] = codec
21+
end
22+
23+
def rotate(**options)
24+
changing_configuration!
25+
26+
options[:secret_generator] ||= @secret_generator
27+
secret_generator_kwargs = options[:secret_generator].parameters.
28+
filter_map { |type, name| name if type == :key || type == :keyreq }
29+
options[:secret_generator_options] = options.extract!(*secret_generator_kwargs)
30+
31+
@rotate_options << options
32+
33+
self
34+
end
35+
36+
def rotate_defaults
37+
rotate()
38+
end
39+
40+
def clear_rotations
41+
changing_configuration!
42+
@rotate_options.clear
43+
self
44+
end
45+
46+
def on_rotation(&callback)
47+
changing_configuration!
48+
@on_rotation = callback
49+
end
50+
51+
private
52+
def changing_configuration!
53+
if @codecs.any?
54+
raise <<~MESSAGE
55+
Cannot change #{self.class} configuration after it has already been applied.
56+
57+
The configuration has been applied with the following salts:
58+
#{@codecs.keys.map { |salt| "- #{salt.inspect}" }.join("\n")}
59+
MESSAGE
60+
end
61+
end
62+
63+
def build_with_rotations(salt)
64+
raise "No options have been configured" if @rotate_options.empty?
65+
@rotate_options.map { |options| build(salt, **options, on_rotation: @on_rotation) }.reduce(&:fall_back_to)
66+
end
67+
68+
def build(salt, secret_generator:, secret_generator_options:, **options)
69+
raise NotImplementedError
70+
end
71+
end
72+
end
73+
end

activesupport/lib/active_support/messages/rotator.rb

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,44 @@ module Rotator # :nodoc:
66
def initialize(*secrets, on_rotation: nil, **options)
77
super(*secrets, **options)
88

9-
@options = options
9+
@secrets = secrets
10+
@options = options
1011
@rotations = []
1112
@on_rotation = on_rotation
1213
end
1314

1415
def rotate(*secrets, **options)
15-
@rotations << build_rotation(*secrets, @options.merge(options))
16+
fall_back_to build_rotation(*secrets, **options)
1617
end
1718

18-
module Encryptor
19+
def fall_back_to(fallback)
20+
@rotations << fallback
21+
self
22+
end
23+
24+
module Encryptor # :nodoc:
1925
include Rotator
2026

2127
def decrypt_and_verify(*args, on_rotation: @on_rotation, **options)
2228
super
2329
rescue MessageEncryptor::InvalidMessage, MessageVerifier::InvalidSignature
2430
run_rotations(on_rotation) { |encryptor| encryptor.decrypt_and_verify(*args, **options) } || raise
2531
end
26-
27-
private
28-
def build_rotation(secret = @secret, sign_secret = @sign_secret, options)
29-
self.class.new(secret, sign_secret, **options)
30-
end
3132
end
3233

33-
module Verifier
34+
module Verifier # :nodoc:
3435
include Rotator
3536

3637
def verified(*args, on_rotation: @on_rotation, **options)
3738
super || run_rotations(on_rotation) { |verifier| verifier.verified(*args, **options) }
3839
end
39-
40-
private
41-
def build_rotation(secret = @secret, options)
42-
self.class.new(secret, **options)
43-
end
4440
end
4541

4642
private
43+
def build_rotation(*secrets, **options)
44+
self.class.new(*secrets, *@secrets.drop(secrets.length), **@options, **options)
45+
end
46+
4747
def run_rotations(on_rotation)
4848
@rotations.find do |rotation|
4949
if message = yield(rotation) rescue next

activesupport/lib/active_support/secure_compare_rotator.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def secure_compare!(other_value, on_rotation: @on_rotation)
4444
end
4545

4646
private
47-
def build_rotation(previous_value, _options)
47+
def build_rotation(previous_value, **_options)
4848
self.class.new(previous_value)
4949
end
5050
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "rotation_coordinator_tests"
4+
5+
class MessageEncryptorsTest < ActiveSupport::TestCase
6+
include RotationCoordinatorTests
7+
8+
test "can override secret generator" do
9+
secret_generator = ->(salt, secret_length:) { salt[0] * secret_length }
10+
coordinator = make_coordinator.rotate(secret_generator: secret_generator)
11+
12+
assert_equal "message", roundtrip("message", coordinator["salt"])
13+
assert_nil roundtrip("message", @coordinator["salt"], coordinator["salt"])
14+
end
15+
16+
test "supports arbitrary secret generator kwargs" do
17+
secret_generator = ->(salt, secret_length:, foo:, bar: nil) { foo[bar] * secret_length }
18+
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)
19+
coordinator.rotate(foo: "foo", bar: 0)
20+
21+
assert_equal "message", roundtrip("message", coordinator["salt"])
22+
end
23+
24+
test "supports separate secrets for encryption and signing" do
25+
secret_generator = proc { |*args, **kwargs| [SECRET_GENERATOR.call(*args, **kwargs), "signing secret"] }
26+
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)
27+
coordinator.rotate_defaults
28+
29+
assert_equal "message", roundtrip("message", coordinator["salt"])
30+
assert_nil roundtrip("message", @coordinator["salt"], coordinator["salt"])
31+
end
32+
33+
private
34+
SECRET_GENERATOR = proc { |salt, secret_length:| "".ljust(secret_length, salt) }
35+
36+
def make_coordinator
37+
ActiveSupport::MessageEncryptors.new(&SECRET_GENERATOR)
38+
end
39+
40+
def roundtrip(message, encryptor, decryptor = encryptor)
41+
decryptor.decrypt_and_verify(encryptor.encrypt_and_sign(message))
42+
rescue ActiveSupport::MessageVerifier::InvalidSignature
43+
nil
44+
end
45+
end

0 commit comments

Comments
 (0)