rails/activesupport/test/message_encryptors_test.rb
Jonathan Hefner d3917f5fdd Use throw for message error handling control flow
There are multiple points of failure when processing a message with
`MessageEncryptor` or `MessageVerifier`, and there several ways we might
want to handle those failures.  For example, swallowing a failure with
`MessageVerifier#verified`, or raising a specific exception with
`MessageVerifier#verify`, or conditionally ignoring a failure when
rotations are configured.

Prior to this commit, the _internal_ logic of handling failures was
implemented using a mix of `nil` return values and raised exceptions.
This commit reimplements the internal logic using `throw` and a few
precisely targeted `rescue`s.  This accomplishes several things:

* Allow rotation of serializers for `MessageVerifier`.  Previously,
  errors from a `MessageVerifier`'s initial serializer were never
  rescued.  Thus, the serializer could not be rotated:

    ```ruby
    old_verifier = ActiveSupport::MessageVerifier.new("secret", serializer: Marshal)
    new_verifier = ActiveSupport::MessageVerifier.new("secret", serializer: JSON)
    new_verifier.rotate(serializer: Marshal)

    message = old_verifier.generate("message")

    new_verifier.verify(message)
    # BEFORE:
    # => raises JSON::ParserError
    # AFTER:
    # => "message"
    ```

* Allow rotation of serializers for `MessageEncryptor` when using a
  non-standard initial serializer.  Similar to `MessageVerifier`, the
  serializer could not be rotated when the initial serializer raised an
  error other than `TypeError` or `JSON::ParserError`, such as
  `Psych::SyntaxError` or a custom error.

* Raise `MessageEncryptor::InvalidMessage` from `decrypt_and_verify`
  regardless of cipher.  Previously, when a `MessageEncryptor` was using
  a non-AEAD cipher such as AES-256-CBC, a corrupt or tampered message
  would raise `MessageVerifier::InvalidSignature` due to reliance on
  `MessageVerifier` for verification.  Now, the verification mechanism
  is transparent to the user:

    ```ruby
    encryptor = ActiveSupport::MessageEncryptor.new("x" * 32, cipher: "aes-256-gcm")
    message = encryptor.encrypt_and_sign("message")
    encryptor.decrypt_and_verify(message.next)
    # => raises ActiveSupport::MessageEncryptor::InvalidMessage

    encryptor = ActiveSupport::MessageEncryptor.new("x" * 32, cipher: "aes-256-cbc")
    message = encryptor.encrypt_and_sign("message")
    encryptor.decrypt_and_verify(message.next)
    # BEFORE:
    # => raises ActiveSupport::MessageVerifier::InvalidSignature
    # AFTER:
    # => raises ActiveSupport::MessageEncryptor::InvalidMessage
    ```

* Support `nil` original value when using `MessageVerifier#verify`.
  Previously, `MessageVerifier#verify` did not work with `nil` original
  values, though both `MessageVerifier#verified` and
  `MessageEncryptor#decrypt_and_verify` do:

    ```ruby
    encryptor = ActiveSupport::MessageEncryptor.new("x" * 32)
    message = encryptor.encrypt_and_sign(nil)

    encryptor.decrypt_and_verify(message)
    # => nil

    verifier = ActiveSupport::MessageVerifier.new("secret")
    message = verifier.generate(nil)

    verifier.verified(message)
    # => nil

    verifier.verify(message)
    # BEFORE:
    # => raises ActiveSupport::MessageVerifier::InvalidSignature
    # AFTER:
    # => nil
    ```

* Improve performance of verifying a message when it has expired and one
  or more rotations have been configured:

    ```ruby
    # frozen_string_literal: true
    require "benchmark/ips"
    require "active_support/all"

    verifier = ActiveSupport::MessageVerifier.new("new secret")
    verifier.rotate("old secret")

    message = verifier.generate({ "data" => "x" * 100 }, expires_at: 1.day.ago)

    Benchmark.ips do |x|
      x.report("expired message") do
        verifier.verified(message)
      end
    end
    ```

  __Before__

    ```
    Warming up --------------------------------------
         expired message     1.442k i/100ms
    Calculating -------------------------------------
         expired message     14.403k (± 1.7%) i/s -     72.100k in   5.007382s
    ```

  __After__

    ```
    Warming up --------------------------------------
         expired message     1.995k i/100ms
    Calculating -------------------------------------
         expired message     19.992k (± 2.0%) i/s -    101.745k in   5.091421s
    ```

Fixes #47185.
2023-02-12 15:16:25 -06:00

55 lines
2.1 KiB
Ruby

# frozen_string_literal: true
require_relative "abstract_unit"
require_relative "rotation_coordinator_tests"
class MessageEncryptorsTest < ActiveSupport::TestCase
include RotationCoordinatorTests
test "can override secret generator" do
secret_generator = ->(salt, secret_length:) { salt[0] * secret_length }
coordinator = make_coordinator.rotate(secret_generator: secret_generator)
assert_equal "message", roundtrip("message", coordinator["salt"])
assert_nil roundtrip("message", @coordinator["salt"], coordinator["salt"])
end
test "supports arbitrary secret generator kwargs" do
secret_generator = ->(salt, secret_length:, foo:, bar: nil) { foo[bar] * secret_length }
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)
coordinator.rotate(foo: "foo", bar: 0)
assert_equal "message", roundtrip("message", coordinator["salt"])
end
test "supports arbitrary secret generator kwargs when using #rotate block" do
secret_generator = ->(salt, secret_length:, foo:, bar: nil) { foo[bar] * secret_length }
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)
coordinator.rotate { { foo: "foo", bar: 0 } }
assert_equal "message", roundtrip("message", coordinator["salt"])
end
test "supports separate secrets for encryption and signing" do
secret_generator = proc { |*args, **kwargs| [SECRET_GENERATOR.call(*args, **kwargs), "signing secret"] }
coordinator = ActiveSupport::MessageEncryptors.new(&secret_generator)
coordinator.rotate_defaults
assert_equal "message", roundtrip("message", coordinator["salt"])
assert_nil roundtrip("message", @coordinator["salt"], coordinator["salt"])
end
private
SECRET_GENERATOR = proc { |salt, secret_length:| "".ljust(secret_length, salt) }
def make_coordinator
ActiveSupport::MessageEncryptors.new(&SECRET_GENERATOR)
end
def roundtrip(message, encryptor, decryptor = encryptor)
decryptor.decrypt_and_verify(encryptor.encrypt_and_sign(message))
rescue ActiveSupport::MessageEncryptor::InvalidMessage
nil
end
end