mongodb/mongo-ruby-driver

View on GitHub
lib/mongo/auth/scram_conversation_base.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# Copyright (C) 2020 MongoDB Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

module Mongo
  module Auth

    # Defines common behavior around authentication conversations between
    # the client and the server.
    #
    # @api private
    class ScramConversationBase < SaslConversationBase

      # The minimum iteration count for SCRAM-SHA-1 and SCRAM-SHA-256.
      MIN_ITER_COUNT = 4096

      # Create the new conversation.
      #
      # @param [ Auth::User ] user The user to converse about.
      # @param [ String | nil ] client_nonce The client nonce to use.
      #   If this conversation is created for a connection that performed
      #   speculative authentication, this client nonce must be equal to the
      #   client nonce used for speculative authentication; otherwise, the
      #   client nonce must not be specified.
      def initialize(user, connection, client_nonce: nil)
        super
        @client_nonce = client_nonce || SecureRandom.base64
      end

      # @return [ String ] client_nonce The client nonce.
      attr_reader :client_nonce

      # Get the id of the conversation.
      #
      # @example Get the id of the conversation.
      #   conversation.id
      #
      # @return [ Integer ] The conversation id.
      attr_reader :id

      # Whether the client verified the ServerSignature from the server.
      #
      # @see https://jira.mongodb.org/browse/SECURITY-621
      #
      # @return [ true | fase ] Whether the server's signature was verified.
      def server_verified?
        !!@server_verified
      end

      # Continue the SCRAM conversation. This sends the client final message
      # to the server after setting the reply from the previous server
      # communication.
      #
      # @param [ BSON::Document ] reply_document The reply document of the
      #   previous message.
      # @param [ Server::Connection ] connection The connection being
      #   authenticated.
      #
      # @return [ Protocol::Message ] The next message to send.
      def continue(reply_document, connection)
        @id = reply_document['conversationId']
        payload_data = reply_document['payload'].data
        parsed_data = parse_payload(payload_data)
        @server_nonce = parsed_data.fetch('r')
        @salt = Base64.strict_decode64(parsed_data.fetch('s'))
        @iterations = parsed_data.fetch('i').to_i.tap do |i|
          if i < MIN_ITER_COUNT
            raise Error::InsufficientIterationCount.new(
              Error::InsufficientIterationCount.message(MIN_ITER_COUNT, i))
          end
        end
        @auth_message = "#{first_bare},#{payload_data},#{without_proof}"

        validate_server_nonce!

        selector = CLIENT_CONTINUE_MESSAGE.merge(
          payload: client_final_message,
          conversationId: id,
        )
        if connection && connection.features.op_msg_enabled?
          selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source
          cluster_time = connection.mongos? && connection.cluster_time
          selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time
          Protocol::Msg.new([], {}, selector)
        else
          Protocol::Query.new(
            user.auth_source,
            Database::COMMAND,
            selector,
            limit: -1,
          )
        end
      end

      # Processes the second response from the server.
      #
      # @param [ BSON::Document ] reply_document The reply document of the
      #   continue response.
      def process_continue_response(reply_document)
        payload_data = parse_payload(reply_document['payload'].data)
        check_server_signature(payload_data)
      end

      # Finalize the SCRAM conversation. This is meant to be iterated until
      # the provided reply indicates the conversation is finished.
      #
      # @param [ Server::Connection ] connection The connection being authenticated.
      #
      # @return [ Protocol::Query ] The next message to send.
      def finalize(connection)
        if connection && connection.features.op_msg_enabled?
          selector = CLIENT_CONTINUE_MESSAGE.merge(
            payload: client_empty_message,
            conversationId: id,
          )
          selector[Protocol::Msg::DATABASE_IDENTIFIER] = user.auth_source
          cluster_time = connection.mongos? && connection.cluster_time
          selector[Operation::CLUSTER_TIME] = cluster_time if cluster_time
          Protocol::Msg.new([], {}, selector)
        else
          Protocol::Query.new(
            user.auth_source,
            Database::COMMAND,
            CLIENT_CONTINUE_MESSAGE.merge(
              payload: client_empty_message,
              conversationId: id,
            ),
            limit: -1,
          )
        end
      end

      # Returns the hash to provide to the server in the handshake
      # as value of the speculativeAuthenticate key.
      #
      # If the auth mechanism does not support speculative authentication,
      # this method returns nil.
      #
      # @return [ Hash | nil ] Speculative authentication document.
      def speculative_auth_document
        client_first_document.merge(db: user.auth_source)
      end

      private

      # Parses a payload like a=value,b=value2 into a hash like
      # {'a' => 'value', 'b' => 'value2'}.
      #
      # @param [ String ] payload The payload to parse.
      #
      # @return [ Hash ] Parsed key-value pairs.
      def parse_payload(payload)
        Hash[payload.split(',').reject { |v| v == '' }.map do |pair|
          k, v, = pair.split('=', 2)
          if k == ''
            raise Error::InvalidServerAuthResponse, 'Payload malformed: missing key'
          end
          [k, v]
        end]
      end

      def client_first_message_options
        {skipEmptyExchange: true}
      end

      # @see http://tools.ietf.org/html/rfc5802#section-3
      def client_first_payload
        "n,,#{first_bare}"
      end

      # Auth message algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      attr_reader :auth_message

      # Get the empty client message.
      #
      # @api private
      #
      # @since 2.0.0
      def client_empty_message
        BSON::Binary.new('')
      end

      # Get the final client message.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def client_final_message
        BSON::Binary.new("#{without_proof},p=#{client_final}")
      end

      # Client final implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-7
      #
      # @since 2.0.0
      def client_final
        @client_final ||= client_proof(client_key,
          client_signature(stored_key(client_key),
          auth_message))
      end

      # Looks for field 'v' in payload data, if it is present verifies the
      # server signature. If verification succeeds, sets @server_verified
      # to true. If verification fails, raises InvalidSignature.
      #
      # This method can be called from different conversation steps
      # depending on whether the short SCRAM conversation is used.
      def check_server_signature(payload_data)
        if verifier = payload_data['v']
          if compare_digest(verifier, server_signature)
            @server_verified = true
          else
            raise Error::InvalidSignature.new(verifier, server_signature)
          end
        end
      end

      # Client key algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def client_key
        @client_key ||= CredentialCache.cache(cache_key(:client_key)) do
          hmac(salted_password, 'Client Key')
        end
      end

      # Client proof algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def client_proof(key, signature)
        @client_proof ||= Base64.strict_encode64(xor(key, signature))
      end

      # Client signature algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def client_signature(key, message)
        @client_signature ||= hmac(key, message)
      end

      # First bare implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-7
      #
      # @since 2.0.0
      def first_bare
        @first_bare ||= "n=#{user.encoded_name},r=#{client_nonce}"
      end

      # H algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-2.2
      #
      # @since 2.0.0
      def h(string)
        digest.digest(string)
      end

      # HMAC algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-2.2
      #
      # @since 2.0.0
      def hmac(data, key)
        OpenSSL::HMAC.digest(digest, data, key)
      end

      # Get the iterations from the server response.
      #
      # @api private
      #
      # @since 2.0.0
      attr_reader :iterations

      # Get the data from the returned payload.
      #
      # @api private
      #
      # @since 2.0.0
      attr_reader :payload_data

      # Get the server nonce from the payload.
      #
      # @api private
      #
      # @since 2.0.0
      attr_reader :server_nonce

      # Gets the salt from the server response.
      #
      # @api private
      #
      # @since 2.0.0
      attr_reader :salt

      # @api private
      def cache_key(*extra)
        [user.password, salt, iterations, @mechanism] + extra
      end

      # Server key algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def server_key
        @server_key ||= CredentialCache.cache(cache_key(:server_key)) do
          hmac(salted_password, 'Server Key')
        end
      end

      # Server signature algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def server_signature
        @server_signature ||= Base64.strict_encode64(hmac(server_key, auth_message))
      end

      # Stored key algorithm implementation.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-3
      #
      # @since 2.0.0
      def stored_key(key)
        h(key)
      end

      # Get the without proof message.
      #
      # @api private
      #
      # @see http://tools.ietf.org/html/rfc5802#section-7
      #
      # @since 2.0.0
      def without_proof
        @without_proof ||= "c=biws,r=#{server_nonce}"
      end

      # XOR operation for two strings.
      #
      # @api private
      #
      # @since 2.0.0
      def xor(first, second)
        first.bytes.zip(second.bytes).map{ |(a,b)| (a ^ b).chr }.join('')
      end

      def compare_digest(a, b)
        check = a.bytesize ^ b.bytesize
        a.bytes.zip(b.bytes){ |x, y| check |= x ^ y.to_i }
        check == 0
      end
    end
  end
end