File: scram_conversation_base.rb

package info (click to toggle)
ruby-mongo 2.21.3-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 14,764 kB
  • sloc: ruby: 108,806; makefile: 5; sh: 2
file content (378 lines) | stat: -rw-r--r-- 10,913 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# frozen_string_literal: true
# rubocop:todo all

# 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,
        )
        build_message(connection, user.auth_source, selector)
      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::Message ] The next message to send.
      def finalize(connection)
        selector = CLIENT_CONTINUE_MESSAGE.merge(
          payload: client_empty_message,
          conversationId: id,
        )
        build_message(connection, user.auth_source, selector)
      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