Commit Graph

4 Commits

Author SHA1 Message Date
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
Jonathan Hefner
61f711a1ff Support Message{Encryptors,Verifiers}#rotate block
This commit adds a block form of `ActiveSupport::MessageEncryptors#rotate`
and `ActiveSupport::MessageVerifiers#rotate`, which supports
fine-grained per-salt rotation options.  The block will receive a salt,
and should return an appropriate options Hash.  The block may also
return `nil` to indicate that the rotation does not apply to the given
salt.  For example:

  ```ruby
  verifiers = ActiveSupport::MessageVerifiers.new { ... }
  verifiers.rotate(serializer: JSON, url_safe: true)
  verifiers.rotate do |salt|
   case salt
   when :foo
     { serializer: Marshal, url_safe: true }
   when :bar
     { serializer: Marshal, url_safe: false }
   end
  end

  # Uses `serializer: JSON, url_safe: true`.
  # Falls back to `serializer: Marshal, url_safe: true`.
  verifiers[:foo]

  # Uses `serializer: JSON, url_safe: true`.
  # Falls back to `serializer: Marshal, url_safe: false`.
  verifiers[:bar]

  # Uses `serializer: JSON, url_safe: true`.
  verifiers[:baz]
  ```

This can be particularly useful when migrating older configurations to a
unified configuration.
2022-12-19 16:35:20 -06:00
Jonathan Hefner
32ea85f1c2 Fix Active Support isolated build
Follow-up to #44179.

Example failure: https://buildkite.com/rails/rails/builds/89840#018377c0-87f6-4d46-a0d9-4add41c4a798/1046-2214
2022-09-25 22:50:24 -05:00
Jonathan Hefner
baade55c69 Add Rails.application.message_verifiers
Currently, `Rails.application.message_verifier(name)` returns a
`MessageVerifier` instance using a secret derived from the given `name`.
Instances created this way always generate messages using the default
options for `MessageVerifier.new`.  Also, if there are older options to
rotate for message verification, each instance created this way must be
configured individually.  In cases where Rails itself uses
`Rails.application.message_verifier` (e.g. in Active Storage),
discovering the name of the instance may require digging through
Rails' source code.

To alleviate these issues, this commit adds a `MessageVerifiers` factory
class, which acts as a central point for configuring and creating
`MessageVerifier` instances.  Options passed to `MessageVerifiers#rotate`
will be applied to `MessageVerifier` instances that `MessageVerifiers#[]`
creates.  So the following:

  ```ruby
  foo_verifier = Rails.application.message_verifier(:foo)
  foo_verifier.rotate(old_options)
  bar_verifier = Rails.application.message_verifier(:bar)
  bar_verifier.rotate(old_options)
  ```

Can be rewritten as:

  ```ruby
  Rails.application.message_verifiers.rotate(old_options)
  foo_verifier = Rails.application.message_verifiers[:foo]
  bar_verifier = Rails.application.message_verifiers[:bar]
  ```

Additionally, `Rails.application.message_verifiers` supports a
`:secret_key_base` option, so old `secret_key_base` values can be
rotated:

  ```ruby
  Rails.application.message_verifiers.rotate(secret_key_base: "old secret_key_base")
  ```

`MessageVerifiers` memoizes `MessageVerifier` instances.  This also
allows `MessageVerifier` instances to be overridden entirely:

  ```ruby
  Rails.application.message_verifiers[:foo] = ActiveSupport::MessageVerifier.new(other_secret)
  ```

For parity, this commit also adds a `MessageEncryptors` factory class,
which fulfills the same role for `MessageEncryptor` instances.
2022-09-22 16:21:31 -05:00