diff options
author | Michael Coyne <mikeycgto@gmail.com> | 2017-09-23 17:16:21 -0400 |
---|---|---|
committer | Michael Coyne <mikeycgto@gmail.com> | 2017-09-23 17:16:21 -0400 |
commit | 39f8ca64cec8667b66628e970211b4d18abbc373 (patch) | |
tree | e71ac29cf6352af844075fb1fb863a6e4b8987ca /activesupport/lib | |
parent | 8b139444dd419306e70792ff286ffecd75d67d23 (diff) | |
download | rails-39f8ca64cec8667b66628e970211b4d18abbc373.tar.gz rails-39f8ca64cec8667b66628e970211b4d18abbc373.tar.bz2 rails-39f8ca64cec8667b66628e970211b4d18abbc373.zip |
Add key rotation message Encryptor and Verifier
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.
Diffstat (limited to 'activesupport/lib')
4 files changed, 170 insertions, 4 deletions
diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index 27620f56be..003fb4c354 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -54,7 +54,37 @@ module ActiveSupport # # Then the messages can be verified and returned upto the expire time. # Thereafter, verifying returns +nil+. + # + # === Rotating keys + # + # This class also defines a +rotate+ method which can be used to rotate out + # encryption keys no longer in use. + # + # This method is called with an options hash where a +:cipher+ option and + # either a +:raw_key+ or +:secret+ option must be defined. If +:raw_key+ is + # defined, it is used directly for the underlying encryption function. If + # the +:secret+ option is defined, a +:salt+ option must also be defined and + # a +KeyGenerator+ instance will be used to derive a key using +:salt+. When + # +:secret+ is used, a +:key_generator+ option may also be defined allowing + # for custom +KeyGenerator+ instances. If CBC encryption is used a + # `:raw_signed_key` or a `:signed_salt` option must also be defined. A + # +:digest+ may also be defined when using CBC encryption. This method can be + # called multiple times and new encryptor instances will be added to the + # rotation stack on each call. + # + # # Specifying the key used for encryption + # crypt.rotate raw_key: old_aead_key, cipher: "aes-256-gcm" + # crypt.rotate raw_key: old_cbc_key, raw_signed_key: old_cbc_sign_key, cipher: "aes-256-cbc", digest: "SHA1" + # + # # Using a KeyGenerator instance with a secret and salt(s) + # crypt.rotate secret: old_aead_secret, salt: old_aead_salt, cipher: "aes-256-gcm" + # crypt.rotate secret: old_cbc_secret, salt: old_cbc_salt, signed_salt: old_cbc_signed_salt, cipher: "aes-256-cbc", digest: "SHA1" + # + # # Specifying the key generator instance + # crypt.rotate key_generator: old_key_gen, salt: old_salt, cipher: "aes-256-gcm" class MessageEncryptor + prepend Messages::Rotator::Encryptor + class << self attr_accessor :use_authenticated_message_encryption #:nodoc: @@ -126,7 +156,7 @@ module ActiveSupport # Decrypt and verify a message. We need to verify the message in order to # avoid padding attacks. Reference: https://www.limited-entropy.com/padding-oracle-attacks/. - def decrypt_and_verify(data, purpose: nil) + def decrypt_and_verify(data, purpose: nil, **) _decrypt(verifier.verify(data), purpose) end diff --git a/activesupport/lib/active_support/message_verifier.rb b/activesupport/lib/active_support/message_verifier.rb index 7110d6d2c9..0be13f6f03 100644 --- a/activesupport/lib/active_support/message_verifier.rb +++ b/activesupport/lib/active_support/message_verifier.rb @@ -4,6 +4,7 @@ require "base64" require_relative "core_ext/object/blank" require_relative "security_utils" require_relative "messages/metadata" +require_relative "messages/rotator" module ActiveSupport # +MessageVerifier+ makes it easy to generate and verify messages which are @@ -73,7 +74,36 @@ module ActiveSupport # Then the messages can be verified and returned upto the expire time. # Thereafter, the +verified+ method returns +nil+ while +verify+ raises # <tt>ActiveSupport::MessageVerifier::InvalidSignature</tt>. + # + # === Rotating keys + # + # This class also defines a +rotate+ method which can be used to rotate out + # verification keys no longer in use. + # + # This method is called with an options hash where a +:digest+ option and + # either a +:raw_key+ or +:secret+ option must be defined. If +:raw_key+ is + # defined, it is used directly for the underlying HMAC function. If the + # +:secret+ option is defined, a +:salt+ option must also be defined and a + # +KeyGenerator+ instance will be used to derive a key using +:salt+. When + # +:secret+ is used, a +:key_generator+ option may also be defined allowing + # for custom +KeyGenerator+ instances. This method can be called multiple + # times and new verifier instances will be added to the rotation stack on + # each call. + # + # # Specifying the key used for verification + # @verifier.rotate raw_key: older_key, digest: "SHA1" + # + # # Specify the digest + # @verifier.rotate raw_key: old_key, digest: "SHA256" + # + # # Using a KeyGenerator instance with a secret and salt + # @verifier.rotate secret: old_secret, salt: old_salt, digest: "SHA1" + # + # # Specifying the key generator instance + # @verifier.rotate key_generator: old_key_gen, salt: old_salt, digest: "SHA256" class MessageVerifier + prepend Messages::Rotator::Verifier + class InvalidSignature < StandardError; end def initialize(secret, options = {}) @@ -120,7 +150,7 @@ module ActiveSupport # # incompatible_message = "test--dad7b06c94abba8d46a15fafaef56c327665d5ff" # verifier.verified(incompatible_message) # => TypeError: incompatible marshal file format - def verified(signed_message, purpose: nil) + def verified(signed_message, purpose: nil, **) if valid_message?(signed_message) begin data = signed_message.split("--".freeze)[0] @@ -145,8 +175,8 @@ module ActiveSupport # # other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit' # other_verifier.verify(signed_message) # => ActiveSupport::MessageVerifier::InvalidSignature - def verify(signed_message, purpose: nil) - verified(signed_message, purpose: purpose) || raise(InvalidSignature) + def verify(*args) + verified(*args) || raise(InvalidSignature) end # Generates a signed message for the provided value. diff --git a/activesupport/lib/active_support/messages/rotation_configuration.rb b/activesupport/lib/active_support/messages/rotation_configuration.rb new file mode 100644 index 0000000000..12566bdb63 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotation_configuration.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + class RotationConfiguration + attr_reader :signed, :encrypted + + def initialize + @signed, @encrypted = [], [] + end + + def rotate(kind = nil, **options) + case kind + when :signed + @signed << options + when :encrypted + @encrypted << options + else + rotate :signed, options + rotate :encrypted, options + end + end + end + end +end diff --git a/activesupport/lib/active_support/messages/rotator.rb b/activesupport/lib/active_support/messages/rotator.rb new file mode 100644 index 0000000000..21ae643138 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotator.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + module Rotator # :nodoc: + def initialize(*args) + super + + @rotations = [] + end + + def rotate(*args) + @rotations << create_rotation(*args) + end + + module Encryptor + include Rotator + + def decrypt_and_verify(*args, on_rotation: nil, **options) + super + rescue MessageEncryptor::InvalidMessage, MessageVerifier::InvalidSignature + run_rotations(on_rotation) { |encryptor| encryptor.decrypt_and_verify(*args, options) } || raise + end + + private + def create_rotation(raw_key: nil, raw_signed_key: nil, **options) + self.class.new \ + raw_key || extract_key(options), + raw_signed_key || extract_signing_key(options), + options.slice(:cipher, :digest, :serializer) + end + + def extract_key(cipher:, salt:, key_generator: nil, secret: nil, **) + key_generator ||= key_generator_for(secret) + key_generator.generate_key(salt, self.class.key_len(cipher)) + end + + def extract_signing_key(cipher:, signed_salt: nil, key_generator: nil, secret: nil, **) + if cipher.downcase.end_with?("cbc") + raise ArgumentError, "missing signed_salt for signing key generation" unless signed_salt + + key_generator ||= key_generator_for(secret) + key_generator.generate_key(signed_salt) + end + end + end + + module Verifier + include Rotator + + def verified(*args, on_rotation: nil, **options) + super || run_rotations(on_rotation) { |verifier| verifier.verified(*args, options) } + end + + private + def create_rotation(raw_key: nil, digest: nil, serializer: nil, **options) + self.class.new(raw_key || extract_key(options), digest: digest, serializer: serializer) + end + + def extract_key(key_generator: nil, secret: nil, salt:) + key_generator ||= key_generator_for(secret) + key_generator.generate_key(salt) + end + end + + private + def key_generator_for(secret) + ActiveSupport::KeyGenerator.new(secret, iterations: 1000) + end + + def run_rotations(on_rotation) + @rotations.find do |rotation| + if message = yield(rotation) rescue next + on_rotation.call if on_rotation + return message + end + end + end + end + end +end |