diff options
author | Kasper Timm Hansen <kaspth@gmail.com> | 2017-09-24 18:57:52 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-09-24 18:57:52 +0200 |
commit | 36888b9387731394f0079fc855ab9ad61ba8dfe5 (patch) | |
tree | 62f522701da93bbc8ce8acfb07efccdaab593938 | |
parent | abd4fd43692cd883068ad27f620fd4c00e546f91 (diff) | |
parent | 8b0af54bbe5ab8b598e980013dd53a50d819b636 (diff) | |
download | rails-36888b9387731394f0079fc855ab9ad61ba8dfe5.tar.gz rails-36888b9387731394f0079fc855ab9ad61ba8dfe5.tar.bz2 rails-36888b9387731394f0079fc855ab9ad61ba8dfe5.zip |
Merge pull request #29716 from mikeycgto/active-support-key-rotator
Add Key Rotation to MessageEncryptor and MessageVerifier and simplify the Cookies middleware
20 files changed, 975 insertions, 253 deletions
diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index a53d8efee1..1d4b27a0f9 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,12 @@ +* Simplify cookies middleware with key rotation support + + Use the `rotate` method for both `MessageEncryptor` and + `MessageVerifier` to add key rotation support for encrypted and + signed cookies. This also helps simplify support for legacy cookie + security. + + *Michael J Coyne* + * Use Capybara registered `:puma` server config. The Capybara registered `:puma` server ensures the puma server is run in process so diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb index 845df500d8..b3831649a8 100644 --- a/actionpack/lib/action_dispatch/middleware/cookies.rb +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -49,6 +49,18 @@ module ActionDispatch get_header Cookies::AUTHENTICATED_ENCRYPTED_COOKIE_SALT end + def use_authenticated_cookie_encryption + get_header Cookies::USE_AUTHENTICATED_COOKIE_ENCRYPTION + end + + def encrypted_cookie_cipher + get_header Cookies::ENCRYPTED_COOKIE_CIPHER + end + + def signed_cookie_digest + get_header Cookies::SIGNED_COOKIE_DIGEST + end + def secret_token get_header Cookies::SECRET_TOKEN end @@ -64,6 +76,11 @@ module ActionDispatch def cookies_digest get_header Cookies::COOKIES_DIGEST end + + def cookies_rotations + get_header Cookies::COOKIES_ROTATIONS + end + # :startdoc: end @@ -157,10 +174,14 @@ module ActionDispatch ENCRYPTED_COOKIE_SALT = "action_dispatch.encrypted_cookie_salt".freeze ENCRYPTED_SIGNED_COOKIE_SALT = "action_dispatch.encrypted_signed_cookie_salt".freeze AUTHENTICATED_ENCRYPTED_COOKIE_SALT = "action_dispatch.authenticated_encrypted_cookie_salt".freeze + USE_AUTHENTICATED_COOKIE_ENCRYPTION = "action_dispatch.use_authenticated_cookie_encryption".freeze + ENCRYPTED_COOKIE_CIPHER = "action_dispatch.encrypted_cookie_cipher".freeze + SIGNED_COOKIE_DIGEST = "action_dispatch.signed_cookie_digest".freeze SECRET_TOKEN = "action_dispatch.secret_token".freeze SECRET_KEY_BASE = "action_dispatch.secret_key_base".freeze COOKIES_SERIALIZER = "action_dispatch.cookies_serializer".freeze COOKIES_DIGEST = "action_dispatch.cookies_digest".freeze + COOKIES_ROTATIONS = "action_dispatch.cookies_rotations".freeze # Cookies can typically store 4096 bytes. MAX_COOKIE_SIZE = 4096 @@ -201,12 +222,7 @@ module ActionDispatch # # cookies.signed[:discount] # => 45 def signed - @signed ||= - if upgrade_legacy_signed_cookies? - UpgradeLegacySignedCookieJar.new(self) - else - SignedCookieJar.new(self) - end + @signed ||= SignedKeyRotatingCookieJar.new(self) end # Returns a jar that'll automatically encrypt cookie values before sending them to the client and will decrypt them for read. @@ -223,18 +239,11 @@ module ActionDispatch # Example: # # cookies.encrypted[:discount] = 45 - # # => Set-Cookie: discount=ZS9ZZ1R4cG1pcUJ1bm80anhQang3dz09LS1mbDZDSU5scGdOT3ltQ2dTdlhSdWpRPT0%3D--ab54663c9f4e3bc340c790d6d2b71e92f5b60315; path=/ + # # => Set-Cookie: discount=DIQ7fw==--K3n//8vvnSbGq9dA--7Xh91HfLpwzbj1czhBiwOg==; path=/ # # cookies.encrypted[:discount] # => 45 def encrypted - @encrypted ||= - if upgrade_legacy_signed_cookies? - UpgradeLegacyEncryptedCookieJar.new(self) - elsif upgrade_legacy_hmac_aes_cbc_cookies? - UpgradeLegacyHmacAesCbcCookieJar.new(self) - else - EncryptedCookieJar.new(self) - end + @encrypted ||= EncryptedKeyRotatingCookieJar.new(self) end # Returns the +signed+ or +encrypted+ jar, preferring +encrypted+ if +secret_key_base+ is set. @@ -256,33 +265,17 @@ module ActionDispatch def upgrade_legacy_hmac_aes_cbc_cookies? request.secret_key_base.present? && - request.authenticated_encrypted_cookie_salt.present? && request.encrypted_signed_cookie_salt.present? && - request.encrypted_cookie_salt.present? + request.encrypted_cookie_salt.present? && + request.use_authenticated_cookie_encryption end - end - - # Passing the ActiveSupport::MessageEncryptor::NullSerializer downstream - # to the Message{Encryptor,Verifier} allows us to handle the - # (de)serialization step within the cookie jar, which gives us the - # opportunity to detect and migrate legacy cookies. - module VerifyAndUpgradeLegacySignedMessage # :nodoc: - def initialize(*args) - super - @legacy_verifier = ActiveSupport::MessageVerifier.new(request.secret_token, serializer: ActiveSupport::MessageEncryptor::NullSerializer) - end - def verify_and_upgrade_legacy_signed_message(name, signed_message) - deserialize(name, @legacy_verifier.verify(signed_message)).tap do |value| - self[name] = { value: value } + def encrypted_cookie_cipher + request.encrypted_cookie_cipher || "aes-256-gcm" end - rescue ActiveSupport::MessageVerifier::InvalidSignature - nil - end - private - def parse(name, signed_message) - super || verify_and_upgrade_legacy_signed_message(name, signed_message) + def signed_cookie_digest + request.signed_cookie_digest || "SHA1" end end @@ -524,6 +517,7 @@ module ActionDispatch module SerializedCookieJars # :nodoc: MARSHAL_SIGNATURE = "\x04\x08".freeze + SERIALIZER = ActiveSupport::MessageEncryptor::NullSerializer protected def needs_migration?(value) @@ -534,12 +528,16 @@ module ActionDispatch serializer.dump(value) end - def deserialize(name, value) + def deserialize(name) + rotate = false + value = yield -> { rotate = true } + if value - if needs_migration?(value) - Marshal.load(value).tap do |v| - self[name] = { value: v } - end + case + when needs_migration?(value) + self[name] = Marshal.load(value) + when rotate + self[name] = serializer.load(value) else serializer.load(value) end @@ -561,24 +559,31 @@ module ActionDispatch def digest request.cookies_digest || "SHA1" end - - def key_generator - request.key_generator - end end - class SignedCookieJar < AbstractCookieJar # :nodoc: + class SignedKeyRotatingCookieJar < AbstractCookieJar # :nodoc: include SerializedCookieJars def initialize(parent_jar) super - secret = key_generator.generate_key(request.signed_cookie_salt) - @verifier = ActiveSupport::MessageVerifier.new(secret, digest: digest, serializer: ActiveSupport::MessageEncryptor::NullSerializer) + + secret = request.key_generator.generate_key(request.signed_cookie_salt) + @verifier = ActiveSupport::MessageVerifier.new(secret, digest: signed_cookie_digest, serializer: SERIALIZER) + + request.cookies_rotations.signed.each do |rotation_options| + @verifier.rotate serializer: SERIALIZER, **rotation_options + end + + if upgrade_legacy_signed_cookies? + @verifier.rotate raw_key: request.secret_token, serializer: SERIALIZER + end end private def parse(name, signed_message) - deserialize name, @verifier.verified(signed_message) + deserialize(name) do |rotate| + @verifier.verified(signed_message, on_rotation: rotate) + end end def commit(options) @@ -588,37 +593,38 @@ module ActionDispatch end end - # UpgradeLegacySignedCookieJar is used instead of SignedCookieJar if - # secrets.secret_token and secret_key_base are both set. It reads - # legacy cookies signed with the old dummy key generator and signs and - # re-saves them using the new key generator to provide a smooth upgrade path. - class UpgradeLegacySignedCookieJar < SignedCookieJar #:nodoc: - include VerifyAndUpgradeLegacySignedMessage - end - - class EncryptedCookieJar < AbstractCookieJar # :nodoc: + class EncryptedKeyRotatingCookieJar < AbstractCookieJar # :nodoc: include SerializedCookieJars def initialize(parent_jar) super - if ActiveSupport::LegacyKeyGenerator === key_generator - raise "You didn't set secret_key_base, which is required for this cookie jar. " \ - "Read the upgrade documentation to learn more about this new config option." + 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) + + request.cookies_rotations.encrypted.each do |rotation_options| + @encryptor.rotate serializer: SERIALIZER, **rotation_options end - cipher = "aes-256-gcm" - key_len = ActiveSupport::MessageEncryptor.key_len(cipher) - secret = key_generator.generate_key(request.authenticated_encrypted_cookie_salt || "")[0, key_len] + if upgrade_legacy_hmac_aes_cbc_cookies? + @encryptor.rotate \ + key_generator: request.key_generator, salt: request.encrypted_cookie_salt, signed_salt: request.encrypted_signed_cookie_salt, + cipher: "aes-256-cbc", digest: digest, serializer: SERIALIZER + end - @encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: ActiveSupport::MessageEncryptor::NullSerializer) + if upgrade_legacy_signed_cookies? + @legacy_verifier = ActiveSupport::MessageVerifier.new(request.secret_token, digest: digest, serializer: SERIALIZER) + end end private def parse(name, encrypted_message) - deserialize name, @encryptor.decrypt_and_verify(encrypted_message) - rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage - nil + deserialize(name) do |rotate| + @encryptor.decrypt_and_verify(encrypted_message, on_rotation: rotate) + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + parse_legacy_signed_message(name, encrypted_message) end def commit(options) @@ -626,39 +632,15 @@ module ActionDispatch raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE end - end - # UpgradeLegacyEncryptedCookieJar is used by ActionDispatch::Session::CookieStore - # instead of EncryptedCookieJar if secrets.secret_token and secret_key_base - # are both set. It reads legacy cookies signed with the old dummy key generator and - # encrypts and re-saves them using the new key generator to provide a smooth upgrade path. - class UpgradeLegacyEncryptedCookieJar < EncryptedCookieJar #:nodoc: - include VerifyAndUpgradeLegacySignedMessage - end + def parse_legacy_signed_message(name, legacy_signed_message) + if defined?(@legacy_verifier) + deserialize(name) do |rotate| + rotate.call - # UpgradeLegacyHmacAesCbcCookieJar is used by ActionDispatch::Session::CookieStore - # to upgrade cookies encrypted with AES-256-CBC with HMAC to AES-256-GCM - class UpgradeLegacyHmacAesCbcCookieJar < EncryptedCookieJar - def initialize(parent_jar) - super - - secret = key_generator.generate_key(request.encrypted_cookie_salt || "")[0, ActiveSupport::MessageEncryptor.key_len] - sign_secret = key_generator.generate_key(request.encrypted_signed_cookie_salt || "") - - @legacy_encryptor = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", digest: digest, serializer: ActiveSupport::MessageEncryptor::NullSerializer) - end - - def decrypt_and_verify_legacy_encrypted_message(name, signed_message) - deserialize(name, @legacy_encryptor.decrypt_and_verify(signed_message)).tap do |value| - self[name] = { value: value } - end - rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage - nil - end - - private - def parse(name, signed_message) - super || decrypt_and_verify_legacy_encrypted_message(name, signed_message) + @legacy_verifier.verified(legacy_signed_message) + end + end end end diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 4743a7ce61..855f2ffa47 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "action_dispatch" +require "active_support/messages/rotation_configuration" module ActionDispatch class Railtie < Rails::Railtie # :nodoc: @@ -18,6 +19,7 @@ module ActionDispatch 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.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie" config.action_dispatch.use_authenticated_cookie_encryption = false config.action_dispatch.perform_deep_munge = true @@ -27,6 +29,8 @@ module ActionDispatch "X-Content-Type-Options" => "nosniff" } + config.action_dispatch.cookies_rotations = ActiveSupport::Messages::RotationConfiguration.new + config.eager_load_namespaces << ActionDispatch initializer "action_dispatch.configure" do |app| @@ -39,8 +43,6 @@ module ActionDispatch ActionDispatch::ExceptionWrapper.rescue_responses.merge!(config.action_dispatch.rescue_responses) ActionDispatch::ExceptionWrapper.rescue_templates.merge!(config.action_dispatch.rescue_templates) - config.action_dispatch.authenticated_encrypted_cookie_salt = "authenticated encrypted cookie" if config.action_dispatch.use_authenticated_cookie_encryption - config.action_dispatch.always_write_cookie = Rails.env.development? if config.action_dispatch.always_write_cookie.nil? ActionDispatch::Cookies::CookieJar.always_write_cookie = config.action_dispatch.always_write_cookie diff --git a/actionpack/test/controller/flash_test.rb b/actionpack/test/controller/flash_test.rb index d92ae0b817..34bc2c0caa 100644 --- a/actionpack/test/controller/flash_test.rb +++ b/actionpack/test/controller/flash_test.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "abstract_unit" -require "active_support/key_generator" +require "active_support/messages/rotation_configuration" class FlashTest < ActionController::TestCase class TestController < ActionController::Base @@ -243,6 +243,7 @@ end class FlashIntegrationTest < ActionDispatch::IntegrationTest SessionKey = "_myapp_session" Generator = ActiveSupport::LegacyKeyGenerator.new("b3c631c314c0bbca50c1b2843150fe33") + Rotations = ActiveSupport::Messages::RotationConfiguration.new class TestController < ActionController::Base add_flash_types :bar @@ -348,6 +349,7 @@ class FlashIntegrationTest < ActionDispatch::IntegrationTest args[0] ||= {} args[0][:env] ||= {} args[0][:env]["action_dispatch.key_generator"] ||= Generator + args[0][:env]["action_dispatch.cookies_rotations"] = Rotations super(path, *args) end diff --git a/actionpack/test/controller/request_forgery_protection_test.rb b/actionpack/test/controller/request_forgery_protection_test.rb index 12ae95d602..eb3d2f34a8 100644 --- a/actionpack/test/controller/request_forgery_protection_test.rb +++ b/actionpack/test/controller/request_forgery_protection_test.rb @@ -2,6 +2,7 @@ require "abstract_unit" require "active_support/log_subscriber/test_helper" +require "active_support/messages/rotation_configuration" # common controller actions module RequestForgeryProtectionActions @@ -630,13 +631,14 @@ end class RequestForgeryProtectionControllerUsingNullSessionTest < ActionController::TestCase class NullSessionDummyKeyGenerator - def generate_key(secret) + def generate_key(secret, length = nil) "03312270731a2ed0d11ed091c2338a06" end end def setup @request.env[ActionDispatch::Cookies::GENERATOR_KEY] = NullSessionDummyKeyGenerator.new + @request.env[ActionDispatch::Cookies::COOKIES_ROTATIONS] = ActiveSupport::Messages::RotationConfiguration.new end test "should allow to set signed cookies" do diff --git a/actionpack/test/dispatch/cookies_test.rb b/actionpack/test/dispatch/cookies_test.rb index cb225c0f62..706d0be9c2 100644 --- a/actionpack/test/dispatch/cookies_test.rb +++ b/actionpack/test/dispatch/cookies_test.rb @@ -3,7 +3,7 @@ require "abstract_unit" require "openssl" require "active_support/key_generator" -require "active_support/message_verifier" +require "active_support/messages/rotation_configuration" class CookieJarTest < ActiveSupport::TestCase attr_reader :request @@ -287,15 +287,25 @@ class CookiesTest < ActionController::TestCase tests TestController - SALT = "b3c631c314c0bbca50c1b2843150fe33" + SECRET_KEY_BASE = "b3c631c314c0bbca50c1b2843150fe33" + SIGNED_COOKIE_SALT = "signed cookie" + ENCRYPTED_COOKIE_SALT = "encrypted cookie" + ENCRYPTED_SIGNED_COOKIE_SALT = "sigend encrypted cookie" + AUTHENTICATED_ENCRYPTED_COOKIE_SALT = "authenticated encrypted cookie" def setup super - @request.env["action_dispatch.key_generator"] = ActiveSupport::KeyGenerator.new(SALT, iterations: 2) + @request.env["action_dispatch.key_generator"] = ActiveSupport::KeyGenerator.new(SECRET_KEY_BASE, iterations: 2) + @request.env["action_dispatch.cookies_rotations"] = ActiveSupport::Messages::RotationConfiguration.new - @request.env["action_dispatch.signed_cookie_salt"] = - @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] = SALT + @request.env["action_dispatch.secret_key_base"] = SECRET_KEY_BASE + @request.env["action_dispatch.use_authenticated_cookie_encryption"] = true + + @request.env["action_dispatch.signed_cookie_salt"] = SIGNED_COOKIE_SALT + @request.env["action_dispatch.encrypted_cookie_salt"] = ENCRYPTED_COOKIE_SALT + @request.env["action_dispatch.encrypted_signed_cookie_salt"] = ENCRYPTED_SIGNED_COOKIE_SALT + @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] = AUTHENTICATED_ENCRYPTED_COOKIE_SALT @request.host = "www.nextangle.com" end @@ -430,28 +440,96 @@ class CookiesTest < ActionController::TestCase assert_equal 45, cookies.signed[:user_id] key_generator = @request.env["action_dispatch.key_generator"] - signed_cookie_salt = @request.env["action_dispatch.signed_cookie_salt"] - secret = key_generator.generate_key(signed_cookie_salt) + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) verifier = ActiveSupport::MessageVerifier.new(secret, serializer: Marshal, digest: "SHA1") assert_equal verifier.generate(45), cookies[:user_id] end def test_signed_cookie_using_custom_digest - @request.env["action_dispatch.cookies_digest"] = "SHA256" + @request.env["action_dispatch.signed_cookie_digest"] = "SHA256" + get :set_signed_cookie cookies = @controller.send :cookies assert_not_equal 45, cookies[:user_id] assert_equal 45, cookies.signed[:user_id] key_generator = @request.env["action_dispatch.key_generator"] - signed_cookie_salt = @request.env["action_dispatch.signed_cookie_salt"] - secret = key_generator.generate_key(signed_cookie_salt) + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) verifier = ActiveSupport::MessageVerifier.new(secret, serializer: Marshal, digest: "SHA256") assert_equal verifier.generate(45), cookies[:user_id] end + def test_signed_cookie_rotations_with_secret_key_base_and_digest + rotated_secret_key_base = "b3c631c314c0bbca50c1b2843150fe33" + rotated_salt = "signed cookie" + + @request.env["action_dispatch.signed_cookie_digest"] = "SHA256" + @request.env["action_dispatch.cookies_rotations"].rotate :signed, + secret: rotated_secret_key_base, salt: rotated_salt, digest: "SHA1" + + old_secret = ActiveSupport::KeyGenerator.new(rotated_secret_key_base, iterations: 1000).generate_key(rotated_salt) + old_message = ActiveSupport::MessageVerifier.new(old_secret, digest: "SHA1", serializer: Marshal).generate(45) + + @request.headers["Cookie"] = "user_id=#{old_message}" + + get :get_signed_cookie + assert_equal 45, @controller.send(:cookies).signed[:user_id] + + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) + verifier = ActiveSupport::MessageVerifier.new(secret, digest: "SHA256", serializer: Marshal) + assert_equal 45, verifier.verify(@response.cookies["user_id"]) + end + + def test_signed_cookie_rotations_with_raw_key_and_digest + rotated_raw_key = "b3c631c314c0bbca50c1b2843150fe33" + + @request.env["action_dispatch.signed_cookie_digest"] = "SHA256" + @request.env["action_dispatch.cookies_rotations"].rotate :signed, + raw_key: rotated_raw_key, digest: "SHA1" + + old_message = ActiveSupport::MessageVerifier.new(rotated_raw_key, digest: "SHA1", serializer: Marshal).generate(45) + + @request.headers["Cookie"] = "user_id=#{old_message}" + + get :get_signed_cookie + assert_equal 45, @controller.send(:cookies).signed[:user_id] + + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) + verifier = ActiveSupport::MessageVerifier.new(secret, digest: "SHA256", serializer: Marshal) + assert_equal 45, verifier.verify(@response.cookies["user_id"]) + end + + def test_signed_cookie_with_legacy_secret_scheme + @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" + + old_message = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33", digest: "SHA1", serializer: Marshal).generate(45) + + @request.headers["Cookie"] = "user_id=#{old_message}" + get :get_signed_cookie + assert_equal 45, @controller.send(:cookies).signed[:user_id] + + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key("signed cookie") + verifier = ActiveSupport::MessageVerifier.new(secret, digest: "SHA1", serializer: Marshal) + assert_equal 45, verifier.verify(@response.cookies["user_id"]) + end + + def test_tampered_with_signed_cookie + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) + + verifier = ActiveSupport::MessageVerifier.new(secret, serializer: Marshal, digest: "SHA1") + message = verifier.generate(45) + + @request.headers["Cookie"] = "user_id=#{Marshal.dump 45}--#{message.split("--").last}" + get :get_signed_cookie + assert_nil @controller.send(:cookies).signed[:user_id] + end + def test_signed_cookie_using_default_serializer get :set_signed_cookie cookies = @controller.send :cookies @@ -494,8 +572,7 @@ class CookiesTest < ActionController::TestCase @request.env["action_dispatch.cookies_serializer"] = :hybrid key_generator = @request.env["action_dispatch.key_generator"] - signed_cookie_salt = @request.env["action_dispatch.signed_cookie_salt"] - secret = key_generator.generate_key(signed_cookie_salt) + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) marshal_value = ActiveSupport::MessageVerifier.new(secret, serializer: Marshal).generate(45) @request.headers["Cookie"] = "user_id=#{marshal_value}" @@ -514,8 +591,8 @@ class CookiesTest < ActionController::TestCase @request.env["action_dispatch.cookies_serializer"] = :hybrid key_generator = @request.env["action_dispatch.key_generator"] - signed_cookie_salt = @request.env["action_dispatch.signed_cookie_salt"] - secret = key_generator.generate_key(signed_cookie_salt) + secret = key_generator.generate_key(@request.env["action_dispatch.signed_cookie_salt"]) + json_value = ActiveSupport::MessageVerifier.new(secret, serializer: JSON).generate(45) @request.headers["Cookie"] = "user_id=#{json_value}" @@ -578,11 +655,10 @@ class CookiesTest < ActionController::TestCase def test_encrypted_cookie_using_hybrid_serializer_can_migrate_marshal_dumped_value_to_json @request.env["action_dispatch.cookies_serializer"] = :hybrid - cipher = "aes-256-gcm" - salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len(cipher)] - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: Marshal) + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.authenticated_encrypted_cookie_salt"], 32) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal) marshal_value = encryptor.encrypt_and_sign("bar") @request.headers["Cookie"] = "foo=#{::Rack::Utils.escape marshal_value}" @@ -592,7 +668,7 @@ class CookiesTest < ActionController::TestCase assert_not_equal "bar", cookies[:foo] assert_equal "bar", cookies.encrypted[:foo] - json_encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: JSON) + json_encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: JSON) assert_not_nil @response.cookies["foo"] assert_equal "bar", json_encryptor.decrypt_and_verify(@response.cookies["foo"]) end @@ -600,11 +676,10 @@ class CookiesTest < ActionController::TestCase def test_encrypted_cookie_using_hybrid_serializer_can_read_from_json_dumped_value @request.env["action_dispatch.cookies_serializer"] = :hybrid - cipher = "aes-256-gcm" - salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len(cipher)] - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: JSON) + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.authenticated_encrypted_cookie_salt"], 32) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: JSON) json_value = encryptor.encrypt_and_sign("bar") @request.headers["Cookie"] = "foo=#{::Rack::Utils.escape json_value}" @@ -691,65 +766,8 @@ class CookiesTest < ActionController::TestCase } end - def test_signed_uses_signed_cookie_jar_if_only_secret_token_is_set - @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = nil - get :set_signed_cookie - assert_kind_of ActionDispatch::Cookies::SignedCookieJar, cookies.signed - end - - def test_signed_uses_signed_cookie_jar_if_only_secret_key_base_is_set - @request.env["action_dispatch.secret_token"] = nil - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - get :set_signed_cookie - assert_kind_of ActionDispatch::Cookies::SignedCookieJar, cookies.signed - end - - def test_signed_uses_upgrade_legacy_signed_cookie_jar_if_both_secret_token_and_secret_key_base_are_set - @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - get :set_signed_cookie - assert_kind_of ActionDispatch::Cookies::UpgradeLegacySignedCookieJar, cookies.signed - end - - def test_signed_or_encrypted_uses_signed_cookie_jar_if_only_secret_token_is_set - @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = nil - get :get_encrypted_cookie - assert_kind_of ActionDispatch::Cookies::SignedCookieJar, cookies.signed_or_encrypted - end - - def test_signed_or_encrypted_uses_encrypted_cookie_jar_if_only_secret_key_base_is_set - @request.env["action_dispatch.secret_token"] = nil - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - get :get_encrypted_cookie - assert_kind_of ActionDispatch::Cookies::EncryptedCookieJar, cookies.signed_or_encrypted - end - - def test_signed_or_encrypted_uses_upgrade_legacy_encrypted_cookie_jar_if_both_secret_token_and_secret_key_base_are_set - @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - get :get_encrypted_cookie - assert_kind_of ActionDispatch::Cookies::UpgradeLegacyEncryptedCookieJar, cookies.signed_or_encrypted - end - - def test_encrypted_uses_encrypted_cookie_jar_if_only_secret_key_base_is_set - @request.env["action_dispatch.secret_token"] = nil - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - get :get_encrypted_cookie - assert_kind_of ActionDispatch::Cookies::EncryptedCookieJar, cookies.encrypted - end - - def test_encrypted_uses_upgrade_legacy_encrypted_cookie_jar_if_both_secret_token_and_secret_key_base_are_set - @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - get :get_encrypted_cookie - assert_kind_of ActionDispatch::Cookies::UpgradeLegacyEncryptedCookieJar, cookies.encrypted - end - def test_legacy_signed_cookie_is_read_and_transparently_upgraded_by_signed_cookie_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33").generate(45) @@ -766,9 +784,6 @@ class CookiesTest < ActionController::TestCase def test_legacy_signed_cookie_is_read_and_transparently_encrypted_by_encrypted_cookie_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - @request.env["action_dispatch.encrypted_cookie_salt"] = "4433796b79d99a7735553e316522acee" - @request.env["action_dispatch.encrypted_signed_cookie_salt"] = "00646eb40062e1b1deff205a27cd30f9" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33").generate("bar") @@ -777,17 +792,14 @@ class CookiesTest < ActionController::TestCase assert_equal "bar", @controller.send(:cookies).encrypted[:foo] - cipher = "aes-256-gcm" - salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len(cipher)] - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: Marshal) + secret = @request.env["action_dispatch.key_generator"].generate_key(@request.env["action_dispatch.authenticated_encrypted_cookie_salt"], 32) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal) assert_equal "bar", encryptor.decrypt_and_verify(@response.cookies["foo"]) end def test_legacy_json_signed_cookie_is_read_and_transparently_upgraded_by_signed_json_cookie_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.cookies_serializer"] = :json @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33", serializer: JSON).generate(45) @@ -805,7 +817,6 @@ class CookiesTest < ActionController::TestCase def test_legacy_json_signed_cookie_is_read_and_transparently_encrypted_by_encrypted_json_cookie_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.cookies_serializer"] = :json @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33", serializer: JSON).generate("bar") @@ -824,7 +835,6 @@ class CookiesTest < ActionController::TestCase def test_legacy_json_signed_cookie_is_read_and_transparently_upgraded_by_signed_json_hybrid_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.cookies_serializer"] = :hybrid @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33", serializer: JSON).generate(45) @@ -842,7 +852,6 @@ class CookiesTest < ActionController::TestCase def test_legacy_json_signed_cookie_is_read_and_transparently_encrypted_by_encrypted_hybrid_cookie_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.cookies_serializer"] = :hybrid @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33", serializer: JSON).generate("bar") @@ -851,17 +860,15 @@ class CookiesTest < ActionController::TestCase assert_equal "bar", @controller.send(:cookies).encrypted[:foo] - cipher = "aes-256-gcm" salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len(cipher)] - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: JSON) + secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len("aes-256-gcm")] + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: JSON) assert_equal "bar", encryptor.decrypt_and_verify(@response.cookies["foo"]) end def test_legacy_marshal_signed_cookie_is_read_and_transparently_upgraded_by_signed_json_hybrid_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.cookies_serializer"] = :hybrid @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" legacy_value = ActiveSupport::MessageVerifier.new("b3c631c314c0bbca50c1b2843150fe33").generate(45) @@ -878,6 +885,8 @@ class CookiesTest < ActionController::TestCase def test_legacy_marshal_signed_cookie_is_read_and_transparently_encrypted_by_encrypted_hybrid_cookie_jar_if_both_secret_token_and_secret_key_base_are_set @request.env["action_dispatch.cookies_serializer"] = :hybrid + + @request.env["action_dispatch.use_authenticated_cookie_encryption"] = true @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" @@ -888,16 +897,14 @@ class CookiesTest < ActionController::TestCase assert_equal "bar", @controller.send(:cookies).encrypted[:foo] - cipher = "aes-256-gcm" salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len(cipher)] - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: JSON) + secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len("aes-256-gcm")] + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: JSON) assert_equal "bar", encryptor.decrypt_and_verify(@response.cookies["foo"]) end def test_legacy_signed_cookie_is_treated_as_nil_by_signed_cookie_jar_if_tampered @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" @request.headers["Cookie"] = "user_id=45" get :get_signed_cookie @@ -908,7 +915,6 @@ class CookiesTest < ActionController::TestCase def test_legacy_signed_cookie_is_treated_as_nil_by_encrypted_cookie_jar_if_tampered @request.env["action_dispatch.secret_token"] = "b3c631c314c0bbca50c1b2843150fe33" - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" @request.headers["Cookie"] = "foo=baz" get :get_encrypted_cookie @@ -918,17 +924,12 @@ class CookiesTest < ActionController::TestCase end def test_legacy_hmac_aes_cbc_encrypted_marshal_cookie_is_upgraded_to_authenticated_encrypted_cookie - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - - @request.env["action_dispatch.encrypted_cookie_salt"] = - @request.env["action_dispatch.encrypted_signed_cookie_salt"] = SALT - key_generator = @request.env["action_dispatch.key_generator"] encrypted_cookie_salt = @request.env["action_dispatch.encrypted_cookie_salt"] encrypted_signed_cookie_salt = @request.env["action_dispatch.encrypted_signed_cookie_salt"] - secret = key_generator.generate_key(encrypted_cookie_salt) + secret = key_generator.generate_key(encrypted_cookie_salt, ActiveSupport::MessageEncryptor.key_len("aes-256-cbc")) sign_secret = key_generator.generate_key(encrypted_signed_cookie_salt) - marshal_value = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len], sign_secret, serializer: Marshal).encrypt_and_sign("bar") + marshal_value = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", serializer: Marshal).encrypt_and_sign("bar") @request.headers["Cookie"] = "foo=#{marshal_value}" @@ -938,27 +939,22 @@ class CookiesTest < ActionController::TestCase assert_not_equal "bar", cookies[:foo] assert_equal "bar", cookies.encrypted[:foo] - aead_cipher = "aes-256-gcm" aead_salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - aead_secret = key_generator.generate_key(aead_salt)[0, ActiveSupport::MessageEncryptor.key_len(aead_cipher)] - aead_encryptor = ActiveSupport::MessageEncryptor.new(aead_secret, cipher: aead_cipher, serializer: Marshal) + aead_secret = key_generator.generate_key(aead_salt, ActiveSupport::MessageEncryptor.key_len("aes-256-gcm")) + aead_encryptor = ActiveSupport::MessageEncryptor.new(aead_secret, cipher: "aes-256-gcm", serializer: Marshal) assert_equal "bar", aead_encryptor.decrypt_and_verify(@response.cookies["foo"]) end def test_legacy_hmac_aes_cbc_encrypted_json_cookie_is_upgraded_to_authenticated_encrypted_cookie @request.env["action_dispatch.cookies_serializer"] = :json - @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - - @request.env["action_dispatch.encrypted_cookie_salt"] = - @request.env["action_dispatch.encrypted_signed_cookie_salt"] = SALT key_generator = @request.env["action_dispatch.key_generator"] encrypted_cookie_salt = @request.env["action_dispatch.encrypted_cookie_salt"] encrypted_signed_cookie_salt = @request.env["action_dispatch.encrypted_signed_cookie_salt"] - secret = key_generator.generate_key(encrypted_cookie_salt) + secret = key_generator.generate_key(encrypted_cookie_salt, ActiveSupport::MessageEncryptor.key_len("aes-256-cbc")) sign_secret = key_generator.generate_key(encrypted_signed_cookie_salt) - marshal_value = ActiveSupport::MessageEncryptor.new(secret[0, ActiveSupport::MessageEncryptor.key_len], sign_secret, serializer: JSON).encrypt_and_sign("bar") + marshal_value = ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: "aes-256-cbc", serializer: JSON).encrypt_and_sign("bar") @request.headers["Cookie"] = "foo=#{marshal_value}" @@ -968,19 +964,17 @@ class CookiesTest < ActionController::TestCase assert_not_equal "bar", cookies[:foo] assert_equal "bar", cookies.encrypted[:foo] - aead_cipher = "aes-256-gcm" aead_salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - aead_secret = key_generator.generate_key(aead_salt)[0, ActiveSupport::MessageEncryptor.key_len(aead_cipher)] - aead_encryptor = ActiveSupport::MessageEncryptor.new(aead_secret, cipher: aead_cipher, serializer: JSON) + aead_secret = key_generator.generate_key(aead_salt)[0, ActiveSupport::MessageEncryptor.key_len("aes-256-gcm")] + aead_encryptor = ActiveSupport::MessageEncryptor.new(aead_secret, cipher: "aes-256-gcm", serializer: JSON) assert_equal "bar", aead_encryptor.decrypt_and_verify(@response.cookies["foo"]) end def test_legacy_hmac_aes_cbc_encrypted_cookie_using_64_byte_key_is_upgraded_to_authenticated_encrypted_cookie @request.env["action_dispatch.secret_key_base"] = "c3b95688f35581fad38df788add315ff" - - @request.env["action_dispatch.encrypted_cookie_salt"] = - @request.env["action_dispatch.encrypted_signed_cookie_salt"] = SALT + @request.env["action_dispatch.encrypted_cookie_salt"] = "b3c631c314c0bbca50c1b2843150fe33" + @request.env["action_dispatch.encrypted_signed_cookie_salt"] = "b3c631c314c0bbca50c1b2843150fe33" # Cookie generated with 64 bytes secret message = ["566d4e75536d686e633246564e6b493062557079626c566d51574d30515430394c53315665564a694e4563786555744f57537454576b396a5a31566a626e52525054303d2d2d34663234333330623130623261306163363562316266323335396164666364613564643134623131"].pack("H*") @@ -991,15 +985,60 @@ class CookiesTest < ActionController::TestCase cookies = @controller.send :cookies assert_not_equal "bar", cookies[:foo] assert_equal "bar", cookies.encrypted[:foo] - cipher = "aes-256-gcm" salt = @request.env["action_dispatch.authenticated_encrypted_cookie_salt"] - secret = @request.env["action_dispatch.key_generator"].generate_key(salt)[0, ActiveSupport::MessageEncryptor.key_len(cipher)] - encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: Marshal) + secret = @request.env["action_dispatch.key_generator"].generate_key(salt, ActiveSupport::MessageEncryptor.key_len("aes-256-gcm")) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal) assert_equal "bar", encryptor.decrypt_and_verify(@response.cookies["foo"]) end + def test_encrypted_cookie_rotations_with_secret_and_salt + rotated_secret_key_base = "b3c631c314c0bbca50c1b2843150fe33" + rotated_salt = "authenticated encrypted cookie" + + @request.env["action_dispatch.encrypted_cookie_cipher"] = "aes-256-gcm" + @request.env["action_dispatch.cookies_rotations"].rotate :encrypted, + secret: rotated_secret_key_base, salt: rotated_salt, cipher: "aes-256-gcm" + + key_len = ActiveSupport::MessageEncryptor.key_len("aes-256-gcm") + + old_secret = ActiveSupport::KeyGenerator.new(rotated_secret_key_base, iterations: 1000).generate_key(rotated_salt, key_len) + old_message = ActiveSupport::MessageEncryptor.new(old_secret, cipher: "aes-256-gcm", serializer: Marshal).encrypt_and_sign("bar") + + @request.headers["Cookie"] = "foo=#{::Rack::Utils.escape old_message}" + + get :get_encrypted_cookie + assert_equal "bar", @controller.send(:cookies).encrypted[:foo] + + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.authenticated_encrypted_cookie_salt"], key_len) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal) + assert_equal "bar", encryptor.decrypt_and_verify(@response.cookies["foo"]) + end + + def test_encrypted_cookie_rotations_with_raw_key + raw_key = "b3c631c314c0bbca50c1b2843150fe33" + + @request.env["action_dispatch.encrypted_cookie_cipher"] = "aes-256-gcm" + @request.env["action_dispatch.cookies_rotations"].rotate :encrypted, + raw_key: raw_key, cipher: "aes-256-gcm" + + key_len = ActiveSupport::MessageEncryptor.key_len("aes-256-gcm") + + old_message = ActiveSupport::MessageEncryptor.new(raw_key, cipher: "aes-256-gcm", serializer: Marshal).encrypt_and_sign(45) + + @request.headers["Cookie"] = "foo=#{::Rack::Utils.escape old_message}" + + get :get_encrypted_cookie + assert_equal 45, @controller.send(:cookies).encrypted[:foo] + + key_generator = @request.env["action_dispatch.key_generator"] + secret = key_generator.generate_key(@request.env["action_dispatch.authenticated_encrypted_cookie_salt"], key_len) + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm", serializer: Marshal) + assert_equal 45, encryptor.decrypt_and_verify(@response.cookies["foo"]) + end + def test_cookie_with_all_domain_option get :set_cookie_with_domain assert_response :success diff --git a/actionpack/test/dispatch/routing_test.rb b/actionpack/test/dispatch/routing_test.rb index 446b65a9b9..44f902c163 100644 --- a/actionpack/test/dispatch/routing_test.rb +++ b/actionpack/test/dispatch/routing_test.rb @@ -3,6 +3,7 @@ require "erb" require "abstract_unit" require "controller/fake_controllers" +require "active_support/messages/rotation_configuration" class TestRoutingMapper < ActionDispatch::IntegrationTest SprocketsApp = lambda { |env| @@ -4947,6 +4948,7 @@ end class FlashRedirectTest < ActionDispatch::IntegrationTest SessionKey = "_myapp_session" Generator = ActiveSupport::LegacyKeyGenerator.new("b3c631c314c0bbca50c1b2843150fe33") + Rotations = ActiveSupport::Messages::RotationConfiguration.new class KeyGeneratorMiddleware def initialize(app) @@ -4955,6 +4957,8 @@ class FlashRedirectTest < ActionDispatch::IntegrationTest def call(env) env["action_dispatch.key_generator"] ||= Generator + env["action_dispatch.cookies_rotations"] ||= Rotations + @app.call(env) end end diff --git a/actionpack/test/dispatch/session/cookie_store_test.rb b/actionpack/test/dispatch/session/cookie_store_test.rb index 6517cf4c99..cf51c47068 100644 --- a/actionpack/test/dispatch/session/cookie_store_test.rb +++ b/actionpack/test/dispatch/session/cookie_store_test.rb @@ -3,11 +3,13 @@ require "abstract_unit" require "stringio" require "active_support/key_generator" +require "active_support/messages/rotation_configuration" class CookieStoreTest < ActionDispatch::IntegrationTest SessionKey = "_myapp_session" SessionSecret = "b3c631c314c0bbca50c1b2843150fe33" Generator = ActiveSupport::LegacyKeyGenerator.new(SessionSecret) + Rotations = ActiveSupport::Messages::RotationConfiguration.new Verifier = ActiveSupport::MessageVerifier.new(SessionSecret, digest: "SHA1") SignedBar = Verifier.generate(foo: "bar", session_id: SecureRandom.hex(16)) @@ -346,6 +348,8 @@ class CookieStoreTest < ActionDispatch::IntegrationTest args[0] ||= {} args[0][:headers] ||= {} args[0][:headers]["action_dispatch.key_generator"] ||= Generator + args[0][:headers]["action_dispatch.cookies_rotations"] ||= Rotations + super(path, *args) end diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 56013c5f95..487984cbd3 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,14 @@ +* Add key rotation support to `MessageEncryptor` and `MessageVerifier` + + This change introduces a `rotate` method to both the `MessageEncryptor` and + `MessageVerifier` classes. This method accepts the same arguments and + options as the given classes' constructor. The `encrypt_and_verify` method + for `MessageEncryptor` and the `verified` method for `MessageVerifier` also + accept an optional keyword argument `:on_rotation` block which is called + when a rotated instance is used to decrypt or verify the message. + + *Michael J Coyne* + * Deprecate `Module#reachable?` method. *bogdanvlviv* 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 diff --git a/activesupport/test/message_encryptor_test.rb b/activesupport/test/message_encryptor_test.rb index 1fbe655642..17baf3550b 100644 --- a/activesupport/test/message_encryptor_test.rb +++ b/activesupport/test/message_encryptor_test.rb @@ -115,6 +115,124 @@ class MessageEncryptorTest < ActiveSupport::TestCase assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message) end + def test_with_rotated_raw_key + old_raw_key = SecureRandom.random_bytes(32) + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old raw key") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate raw_key: old_raw_key, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old raw key", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_secret_and_salt + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt, 32) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old secret and salt") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate secret: old_secret, salt: old_salt, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old secret and salt", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_key_generator + old_key_gen, old_salt = ActiveSupport::KeyGenerator.new(SecureRandom.random_bytes(32), iterations: 256), "old salt" + + old_raw_key = old_key_gen.generate_key(old_salt, 32) + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old key generator and salt") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate key_generator: old_key_gen, salt: old_salt, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old key generator and salt", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_aes_cbc_encryptor_with_raw_keys + old_raw_key, old_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old raw keys") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate raw_key: old_raw_key, raw_signed_key: old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + + assert_equal "message encrypted with old raw keys", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_aes_cbc_encryptor_with_secret_and_salts + old_secret, old_salt, old_signed_salt = SecureRandom.random_bytes(32), "old salt", "old signed salt" + + old_key_gen = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000) + old_raw_key = old_key_gen.generate_key(old_salt, 32) + old_raw_signed_key = old_key_gen.generate_key(old_signed_salt) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old secret and salts") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate secret: old_secret, salt: old_salt, signed_salt: old_signed_salt, cipher: "aes-256-cbc", digest: "SHA1" + + assert_equal "message encrypted with old secret and salts", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotating_multiple_encryptors + older_raw_key, older_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + old_raw_key, old_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + + older_encryptor = ActiveSupport::MessageEncryptor.new(older_raw_key, older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + older_message = older_encryptor.encrypt_and_sign("message encrypted with older raw key") + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old raw key") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate raw_key: old_raw_key, raw_signed_key: old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + encryptor.rotate raw_key: older_raw_key, raw_signed_key: older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + + assert_equal "encrypted message", encryptor.decrypt_and_verify(encryptor.encrypt_and_sign("encrypted message")) + assert_equal "message encrypted with old raw key", encryptor.decrypt_and_verify(old_message) + assert_equal "message encrypted with older raw key", encryptor.decrypt_and_verify(older_message) + end + + def test_on_rotation_instance_callback_is_called_and_returns_modified_messages + callback_ran, message = nil, nil + + older_raw_key, older_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + old_raw_key, old_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + + older_encryptor = ActiveSupport::MessageEncryptor.new(older_raw_key, older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + older_message = older_encryptor.encrypt_and_sign(encoded: "message") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate raw_key: old_raw_key, raw_signed_key: old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + encryptor.rotate raw_key: older_raw_key, raw_signed_key: older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + + message = encryptor.decrypt_and_verify(older_message, on_rotation: proc { callback_ran = true }) + + assert callback_ran, "callback was ran" + assert_equal({ encoded: "message" }, message) + end + + def test_with_rotated_metadata + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt, 32) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign( + "message encrypted with old secret, salt, and metadata", purpose: "rotation") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate secret: old_secret, salt: old_salt, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old secret, salt, and metadata", + encryptor.decrypt_and_verify(old_message, purpose: "rotation") + end + private def assert_aead_not_decrypted(encryptor, value) assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do diff --git a/activesupport/test/message_verifier_test.rb b/activesupport/test/message_verifier_test.rb index fbeafca203..3079c48c02 100644 --- a/activesupport/test/message_verifier_test.rb +++ b/activesupport/test/message_verifier_test.rb @@ -20,6 +20,7 @@ class MessageVerifierTest < ActiveSupport::TestCase def setup @verifier = ActiveSupport::MessageVerifier.new("Hey, I'm a secret!") @data = { some: "data", now: Time.utc(2010) } + @secret = SecureRandom.random_bytes(32) end def test_valid_message @@ -90,6 +91,95 @@ class MessageVerifierTest < ActiveSupport::TestCase signed_message = "BAh7BzoJc29tZUkiCWRhdGEGOgZFVDoIbm93SXU6CVRpbWUNIIAbgAAAAAAHOgtvZmZzZXRpADoJem9uZUkiCFVUQwY7BkY=--d03c52c91dfe4ccc5159417c660461bcce005e96" assert_equal @data, @verifier.verify(signed_message) end + + def test_with_rotated_raw_key + old_raw_key = SecureRandom.random_bytes(32) + + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate("message verified with old raw key") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate raw_key: old_raw_key, digest: "SHA1" + + assert_equal "message verified with old raw key", verifier.verified(old_message) + end + + def test_with_rotated_secret_and_salt + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt) + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate("message verified with old secret and salt") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate secret: old_secret, salt: old_salt, digest: "SHA1" + + assert_equal "message verified with old secret and salt", verifier.verified(old_message) + end + + def test_with_rotated_key_generator + old_key_gen, old_salt = ActiveSupport::KeyGenerator.new(SecureRandom.random_bytes(32), iterations: 256), "old salt" + + old_raw_key = old_key_gen.generate_key(old_salt) + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate("message verified with old key generator and salt") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate key_generator: old_key_gen, salt: old_salt, digest: "SHA1" + + assert_equal "message verified with old key generator and salt", verifier.verified(old_message) + end + + def test_with_rotating_multiple_verifiers + old_raw_key, older_raw_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(32) + + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA256") + old_message = old_verifier.generate("message verified with old raw key") + + older_verifier = ActiveSupport::MessageVerifier.new(older_raw_key, digest: "SHA1") + older_message = older_verifier.generate("message verified with older raw key") + + verifier = ActiveSupport::MessageVerifier.new("new secret", digest: "SHA512") + verifier.rotate raw_key: old_raw_key, digest: "SHA256" + verifier.rotate raw_key: older_raw_key, digest: "SHA1" + + assert_equal "verified message", verifier.verified(verifier.generate("verified message")) + assert_equal "message verified with old raw key", verifier.verified(old_message) + assert_equal "message verified with older raw key", verifier.verified(older_message) + end + + def test_on_rotation_keyword_block_is_called_and_verified_returns_message + callback_ran, message = nil, nil + + old_raw_key, older_raw_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(32) + + older_verifier = ActiveSupport::MessageVerifier.new(older_raw_key, digest: "SHA1") + older_message = older_verifier.generate(encoded: "message") + + verifier = ActiveSupport::MessageVerifier.new("new secret", digest: "SHA512") + verifier.rotate raw_key: old_raw_key, digest: "SHA256" + verifier.rotate raw_key: older_raw_key, digest: "SHA1" + + message = verifier.verified(older_message, on_rotation: proc { callback_ran = true }) + + assert callback_ran, "callback was ran" + assert_equal({ encoded: "message" }, message) + end + + def test_with_rotated_metadata + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt) + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate( + "message verified with old secret, salt, and metadata", purpose: "rotation") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate secret: old_secret, salt: old_salt, digest: "SHA1" + + assert_equal "message verified with old secret, salt, and metadata", + verifier.verified(old_message, purpose: "rotation") + end end class MessageVerifierMetadataTest < ActiveSupport::TestCase diff --git a/activesupport/test/messages/rotation_configuration_test.rb b/activesupport/test/messages/rotation_configuration_test.rb new file mode 100644 index 0000000000..41d938e119 --- /dev/null +++ b/activesupport/test/messages/rotation_configuration_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/messages/rotation_configuration" + +class MessagesRotationConfiguration < ActiveSupport::TestCase + def setup + @config = ActiveSupport::Messages::RotationConfiguration.new + end + + def test_signed_configurations + @config.rotate :signed, secret: "older secret", salt: "salt", digest: "SHA1" + @config.rotate :signed, secret: "old secret", salt: "salt", digest: "SHA256" + + assert_equal [{ + secret: "older secret", salt: "salt", digest: "SHA1" + }, { + secret: "old secret", salt: "salt", digest: "SHA256" + }], @config.signed + end + + def test_encrypted_configurations + @config.rotate :encrypted, raw_key: "old raw key", cipher: "aes-256-gcm" + + assert_equal [{ + raw_key: "old raw key", cipher: "aes-256-gcm" + }], @config.encrypted + end + + def test_rotate_without_kind + @config.rotate secret: "older secret", salt: "salt", digest: "SHA1" + @config.rotate raw_key: "old raw key", cipher: "aes-256-gcm" + + expected = [{ + secret: "older secret", salt: "salt", digest: "SHA1" + }, { + raw_key: "old raw key", cipher: "aes-256-gcm" + }] + + assert_equal expected, @config.encrypted + assert_equal expected, @config.signed + end +end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 1c720ad82f..86c8364d83 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -487,6 +487,17 @@ Defaults to `'signed cookie'`. authenticated encrypted cookie salt. Defaults to `'authenticated encrypted cookie'`. +* `config.action_dispatch.encrypted_cookie_cipher` sets the cipher to be + used for encrypted cookies. This defaults to `"aes-256-gcm"`. + +* `config.action_dispatch.signed_cookie_digest` sets the digest to be + used for signed cookies. This defaults to `"SHA1"`. + +* `config.action_dispatch.cookies_rotations` is set to an instance of + [RotationConfiguration](http://api.rubyonrails.org/classes/ActiveSupport/RotationConfiguration.html). + It provides an interface for rotating keys, salts, ciphers, and + digests for encrypted and signed cookies. + * `config.action_dispatch.perform_deep_munge` configures whether `deep_munge` method should be performed on the parameters. See [Security Guide](security.html#unsafe-query-generation) for more information. It defaults to `true`. diff --git a/guides/source/security.md b/guides/source/security.md index 0b2d8de0fb..b0b71cad7d 100644 --- a/guides/source/security.md +++ b/guides/source/security.md @@ -85,37 +85,129 @@ This will also be a good idea, if you modify the structure of an object and old * _Critical data should not be stored in session_. If the user clears their cookies or closes the browser, they will be lost. And with a client-side session storage, the user can read the data. -### Session Storage +### Encrypted Session Storage NOTE: _Rails provides several storage mechanisms for the session hashes. The most important is `ActionDispatch::Session::CookieStore`._ -Rails 2 introduced a new default session storage, CookieStore. CookieStore saves the session hash directly in a cookie on the client-side. The server retrieves the session hash from the cookie and eliminates the need for a session ID. That will greatly increase the speed of the application, but it is a controversial storage option and you have to think about the security implications of it: +The `CookieStore` saves the session hash directly in a cookie on the +client-side. The server retrieves the session hash from the cookie and +eliminates the need for a session ID. That will greatly increase the +speed of the application, but it is a controversial storage option and +you have to think about the security implications and storage +limitations of it: + +* Cookies imply a strict size limit of 4kB. This is fine as you should + not store large amounts of data in a session anyway, as described + before. Storing the current user's database id in a session is common + practice. + +* Session cookies do not invalidate themselves and can be maliciously + reused. It may be a good idea to have your application invalidate old + session cookies using a stored timestamp. + +The `CookieStore` uses the +[encrypted](http://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-encrypted) +cookie jar to provide a secure, encrypted location to store session +data. Cookie-based sessions thus provide both integrity as well as +confidentiality to their contents. The encryption key, as well as the +verification key used for +[signed](http://api.rubyonrails.org/classes/ActionDispatch/Cookies/ChainedCookieJars.html#method-i-signed) +cookies, is derived from the `secret_key_base` configuration value. + +As of Rails 5.2 encrypted cookies and sessions are protected using AES +GCM encryption. This form of encryption is a type of Authenticated +Encryption and couples authentication and encryption in single step +while also producing shorter ciphertexts as compared to other +algorithms previously used. The key for cookies encrypted with AES GCM +are derived using a salt value defined by the +`config.action_dispatch.authenticated_encrypted_cookie_salt` +configuration value. + +Prior to this version, encrypted cookies were secured using AES in CBC +mode with HMAC using SHA1 for authentication. The keys for this type of +encryption and for HMAC verification were derived via the salts defined +by `config.action_dispatch.encrypted_cookie_salt` and +`config.action_dispatch.encrypted_signed_cookie_salt` respectively. + +Prior to Rails version 4 in both versions 2 and 3, session cookies were +protected using only HMAC verification. As such, these session cookies +only provided integrity to their content because the actual session data +was stored in plaintext encoded as base64. This is how `signed` cookies +work in the current version of Rails. These kinds of cookies are still +useful for protecting the integrity of certain client-stored data and +information. + +__Do not use a trivial secret for the `secret_key_base`, i.e. a word +from a dictionary, or one which is shorter than 30 characters! Instead +use `rails secret` to generate secret keys!__ + +It is also important to use different salt values for encrypted and +signed cookies. Using the same value for different salt configuration +values may lead to the same derived key being used for different +security features which in turn may weaken the strength of the key. -* Cookies imply a strict size limit of 4kB. This is fine as you should not store large amounts of data in a session anyway, as described before. _Storing the current user's database id in a session is usually ok_. +In test and development applications get a `secret_key_base` derived from the app name. Other environments must use a random key present in `config/credentials.yml.enc`, shown here in its decrypted state: -* The client can see everything you store in a session, because it is stored in clear-text (actually Base64-encoded, so not encrypted). So, of course, _you don't want to store any secrets here_. To prevent session hash tampering, a digest is calculated from the session with a server-side secret (`secrets.secret_token`) and inserted into the end of the cookie. + secret_key_base: 492f... -In Rails 4, encrypted cookies through AES in CBC mode with HMAC using SHA1 for -verification was introduced. This prevents the user from accessing and tampering -the content of the cookie. Thus the session becomes a more secure place to store -data. The encryption is performed using a server-side `secret_key_base`. -Two salts are used when deriving keys for encryption and verification. These -salts are set via the `config.action_dispatch.encrypted_cookie_salt` and -`config.action_dispatch.encrypted_signed_cookie_salt` configuration values. +If you have received an application where the secret was exposed (e.g. an application whose source was shared), strongly consider changing the secret. -Rails 5.2 uses AES-GCM for the encryption which couples authentication -and encryption in one faster step and produces shorter ciphertexts. +### Rotating Keys for Encrypted and Signed Cookies -Encrypted cookies are automatically upgraded if the -`config.action_dispatch.use_authenticated_cookie_encryption` is enabled. +It is possible to rotate the `secret_key_base` as well as the salts, +ciphers, and digests used for both encrypted and signed cookies. Rotating +the `secret_key_base` is necessary if the value was exposed or leaked. +It is also useful to rotate this value for other more benign reasons, +such as an employee leaving your organization or changing hosting +environments. -_Do not use a trivial secret, i.e. a word from a dictionary, or one which is shorter than 30 characters! Instead use `rails secret` to generate secret keys!_ +Key rotations can be defined through the +`config.action_dispatch.cookies_rotations` configuration value. This +value is set to an instance of +[RotationConfiguration](http://api.rubyonrails.org/classes/ActiveSupport/RotationConfiguration.html) +which provides an interface for rotating signed and encrypted cookie +keys, salts, digests, and ciphers. -In test and development applications get a `secret_key_base` derived from the app name. Other environments must use a random key present in `config/credentials.yml.enc`, shown here in its decrypted state: +For example, suppose we want to rotate out an old `secret_key_base`, we +can define a signed and encrypted key rotation as follows: - secret_key_base: 492f... +```ruby +config.action_dispatch.cookies_rotations.rotate :encrypted, + cipher: "aes-256-gcm", + secret: Rails.application.credentials.old_secret_key_base, + salt: config.action_dispatch.authenticated_encrypted_cookie_salt -If you have received an application where the secret was exposed (e.g. an application whose source was shared), strongly consider changing the secret. +config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA1", + secret: Rails.application.credentials.old_secret_key_base, + salt: config.action_dispatch.signed_cookie_salt +``` + +Multiple rotations are possible by calling `rotate` multiple times. For +example, suppose we want to use SHA512 for signed cookies while rotating +out SHA256 and SHA1 digests using the same `secret_key_base` + +```ruby +config.action_dispatch.signed_cookie_digest = "SHA512" + +config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA256", + secret: Rails.application.credentials.secret_key_base, + salt: config.action_dispatch.signed_cookie_salt + +config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA1", + secret: Rails.application.credentials.secret_key_base, + salt: config.action_dispatch.signed_cookie_salt +``` + +For more details on key rotation with encrypted and signed messages as +well as the various options the `rotate` method accepts, please refer to +the +[MessageEncryptor API](api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) +and +[MessageVerifier API](api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html) +documentation. ### Replay Attacks for CookieStore Sessions diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index abfec90b6d..24f5eeae87 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -259,8 +259,12 @@ module Rails "action_dispatch.encrypted_cookie_salt" => config.action_dispatch.encrypted_cookie_salt, "action_dispatch.encrypted_signed_cookie_salt" => config.action_dispatch.encrypted_signed_cookie_salt, "action_dispatch.authenticated_encrypted_cookie_salt" => config.action_dispatch.authenticated_encrypted_cookie_salt, + "action_dispatch.use_authenticated_cookie_encryption" => config.action_dispatch.use_authenticated_cookie_encryption, + "action_dispatch.encrypted_cookie_cipher" => config.action_dispatch.encrypted_cookie_cipher, + "action_dispatch.signed_cookie_digest" => config.action_dispatch.signed_cookie_digest, "action_dispatch.cookies_serializer" => config.action_dispatch.cookies_serializer, - "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest + "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest, + "action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations ) end end diff --git a/railties/test/application/middleware/cookies_test.rb b/railties/test/application/middleware/cookies_test.rb index 23f1ec3e35..932a6d0e77 100644 --- a/railties/test/application/middleware/cookies_test.rb +++ b/railties/test/application/middleware/cookies_test.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require "isolation/abstract_unit" +require "rack/test" module ApplicationTests class CookiesTest < ActiveSupport::TestCase include ActiveSupport::Testing::Isolation + include Rack::Test::Methods def new_app File.expand_path("#{app_path}/../new_app") @@ -15,6 +17,10 @@ module ApplicationTests FileUtils.rm_rf("#{app_path}/config/environments") end + def app + Rails.application + end + def teardown teardown_app FileUtils.rm_rf(new_app) if File.directory?(new_app) @@ -44,5 +50,142 @@ module ApplicationTests require "#{app_path}/config/environment" assert_equal false, ActionDispatch::Cookies::CookieJar.always_write_cookie end + + test "signed cookies with SHA512 digest and rotated out SHA256 and SHA1 digests" do + key_gen_sha1 = ActiveSupport::KeyGenerator.new("legacy sha1 secret", iterations: 1000) + key_gen_sha256 = ActiveSupport::KeyGenerator.new("legacy sha256 secret", iterations: 1000) + + verifer_sha1 = ActiveSupport::MessageVerifier.new(key_gen_sha1.generate_key("sha1 salt"), digest: :SHA1) + verifer_sha256 = ActiveSupport::MessageVerifier.new(key_gen_sha256.generate_key("sha256 salt"), digest: :SHA256) + + app_file "config/routes.rb", <<-RUBY + Rails.application.routes.draw do + get ':controller(/:action)' + post ':controller(/:action)' + end + RUBY + + controller :foo, <<-RUBY + class FooController < ActionController::Base + protect_from_forgery with: :null_session + + def write_raw_cookie_sha1 + cookies[:signed_cookie] = "#{verifer_sha1.generate("signed cookie")}" + head :ok + end + + def write_raw_cookie_sha256 + cookies[:signed_cookie] = "#{verifer_sha256.generate("signed cookie")}" + head :ok + end + + def read_signed + render plain: cookies.signed[:signed_cookie].inspect + end + + def read_raw_cookie + render plain: cookies[:signed_cookie] + end + end + RUBY + + add_to_config <<-RUBY + config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA1", secret: "legacy sha1 secret", salt: "sha1 salt" + + config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA256", secret: "legacy sha256 secret", salt: "sha256 salt" + + config.action_dispatch.signed_cookie_digest = "SHA512" + config.action_dispatch.signed_cookie_salt = "sha512 salt" + RUBY + + require "#{app_path}/config/environment" + + verifer_sha512 = ActiveSupport::MessageVerifier.new(app.key_generator.generate_key("sha512 salt"), digest: :SHA512) + + get "/foo/write_raw_cookie_sha1" + get "/foo/read_signed" + assert_equal "signed cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "signed cookie", verifer_sha512.verify(last_response.body) + + get "/foo/write_raw_cookie_sha256" + get "/foo/read_signed" + assert_equal "signed cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "signed cookie", verifer_sha512.verify(last_response.body) + end + + test "encrypted cookies with multiple rotated out ciphers" do + key_gen_one = ActiveSupport::KeyGenerator.new("legacy secret one", iterations: 1000) + key_gen_two = ActiveSupport::KeyGenerator.new("legacy secret two", iterations: 1000) + + encryptor_one = ActiveSupport::MessageEncryptor.new(key_gen_one.generate_key("salt one", 32), cipher: "aes-256-gcm") + encryptor_two = ActiveSupport::MessageEncryptor.new(key_gen_two.generate_key("salt two", 32), cipher: "aes-256-gcm") + + app_file "config/routes.rb", <<-RUBY + Rails.application.routes.draw do + get ':controller(/:action)' + post ':controller(/:action)' + end + RUBY + + controller :foo, <<-RUBY + class FooController < ActionController::Base + protect_from_forgery with: :null_session + + def write_raw_cookie_one + cookies[:encrypted_cookie] = "#{encryptor_one.encrypt_and_sign("encrypted cookie")}" + head :ok + end + + def write_raw_cookie_two + cookies[:encrypted_cookie] = "#{encryptor_two.encrypt_and_sign("encrypted cookie")}" + head :ok + end + + def read_encrypted + render plain: cookies.encrypted[:encrypted_cookie].inspect + end + + def read_raw_cookie + render plain: cookies[:encrypted_cookie] + end + end + RUBY + + add_to_config <<-RUBY + config.action_dispatch.use_authenticated_cookie_encryption = true + config.action_dispatch.encrypted_cookie_cipher = "aes-256-gcm" + config.action_dispatch.authenticated_encrypted_cookie_salt = "salt" + + config.action_dispatch.cookies_rotations.rotate :encrypted, + cipher: "aes-256-gcm", secret: "legacy secret one", salt: "salt one" + + config.action_dispatch.cookies_rotations.rotate :encrypted, + cipher: "aes-256-gcm", secret: "legacy secret two", salt: "salt two" + RUBY + + require "#{app_path}/config/environment" + + encryptor = ActiveSupport::MessageEncryptor.new(app.key_generator.generate_key("salt", 32), cipher: "aes-256-gcm") + + get "/foo/write_raw_cookie_one" + get "/foo/read_encrypted" + assert_equal "encrypted cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body) + + get "/foo/write_raw_cookie_sha256" + get "/foo/read_encrypted" + assert_equal "encrypted cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body) + end end end |