Skip to content
  • Jonathan Hefner's avatar
    d3917f5f
    Use throw for message error handling control flow · d3917f5f
    Jonathan Hefner authored
    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.
    d3917f5f
    Use throw for message error handling control flow
    Jonathan Hefner authored
    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.
Loading