File: scram.rb

package info (click to toggle)
ruby-em-mongo 0.6.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 340 kB
  • sloc: ruby: 2,960; makefile: 2
file content (237 lines) | stat: -rw-r--r-- 8,683 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
require 'openssl'
require 'bson'
require 'eventmachine'

require_relative '../support.rb'

module EM::Mongo

  # an RFC 5802 compilant SCRAM(-SHA-1) implementation
  # for MongoDB-Authentication
  #
  # so everything is encapsulated, but the main part (PAYLOAD of messages) is RFC5802 compilant
  class SCRAM < Authentication

      MECHANISM = 'SCRAM-SHA-1'.freeze

      DIGEST = OpenSSL::Digest::SHA1.new.freeze

      CLIENT_FIRST_MESSAGE = { saslStart: 1, autoAuthorize: 1 }.freeze
      CLIENT_FINAL_MESSAGE = CLIENT_EMPTY_MESSAGE = { saslContinue: 1 }.freeze


      CLIENT_KEY = 'Client Key'.freeze
      SERVER_KEY = 'Server Key'.freeze

      RNONCE = /r=([^,]*)/.freeze
      SALT = /s=([^,]*)/.freeze
      ITERATIONS = /i=(\d+)/.freeze
      VERIFIER = /v=([^,]*)/.freeze
      PAYLOAD = 'payload'.freeze

      # @param [String] username
      # @param [String] password
      #
      # @return [EM::Mongo::RequestResponse] Calls back with +true+ or +false+, indicating success or failure
      #
      # @raise [AuthenticationError]
      #
      # @core authenticate authenticate-instance_method
      def authenticate(username, password)
        response = RequestResponse.new
        
        #TODO look for fail-fast-ness (strange word!?)
        #TODO Flatten Hierarchies
        @username = username
        @plain_password = password

        gs2_header = 'n,,'
        client_first_bare = "n=#{@username},r=#{client_nonce}"

        client_first = BSON::Binary.new(gs2_header+client_first_bare) # client_first msg
        client_first_msg = CLIENT_FIRST_MESSAGE.merge({PAYLOAD=>client_first, mechanism:MECHANISM})

        client_first_resp = @db.collection(EM::Mongo::Database::SYSTEM_COMMAND_COLLECTION).first(client_first_msg) #TODO extract and make easier to understand (e.g. command(first_msg) or sthg like that)

        #server_first_resp #for flattening

        client_first_resp.callback do |res_first|
          if not is_server_response_valid? res_first
            response.fail "first server response not valid: " + res_first.to_s
          else
            # take the salt & iterations and do the pw-derivation
            server_first = res_first[PAYLOAD].to_s

            @conversation_id=conv_id = res_first['conversationId']

            combined_nonce = server_first.match(RNONCE)[1] #r= ...
            salt       =     server_first.match( SALT )[1] #s=... (from server_first)
            iterations = server_first.match(ITERATIONS)[1].to_i #i=...  ..

            if not combined_nonce.start_with?(client_nonce) # combined_nonce should be client_nonce+server_nonce
              response.fail "nonce doesn't start with client_nonce: " + res_first.to_s
            else
              client_final_wo_proof= "c=#{Base64.strict_encode64(gs2_header)},r=#{combined_nonce}" #c='biws'
              auth_message = client_first_bare + ',' + server_first + ',' + client_final_wo_proof

              # proof = clientKey XOR clientSig  ## needs to be sent back
              #
              # ClientSign  = HMAC(StoredKey, AuthMessage)
              # StoredKey = H(ClientKey) ## lt. RFC5802 (needs to be verified against ruby-mongo driver impl)
              # AuthMessage = client_first_bare + ','+server_first+','+client_final_wo_proof

              @salt = salt
              @iterations = iterations
              #client_key = client_key()

              @auth_message = auth_message
              #client_signature = client_signature()

              proof = Base64.strict_encode64(xor(client_key, client_signature))
              client_final = BSON::Binary.new ( client_final_wo_proof + ",p=#{proof}")
              client_final_msg = CLIENT_FINAL_MESSAGE.merge({PAYLOAD => client_final, conversationId: conv_id})

              client_final_resp = @db.collection(SYSTEM_COMMAND_COLLECTION).first(client_final_msg)
              client_final_resp.callback do |res_final|
                if not is_server_response_valid? res_final
                  response.fail "Final Server Response not valid " + res_final.to_s
                else
                  server_final = res_final[PAYLOAD].to_s # in RFC this equals server_final
                  verifier = server_final.match(VERIFIER)[1] #r= ...
                  if verifier and verifier_valid? verifier
                    handle_server_end(response,conv_id) # will set the response
                  else
                    response.fail "verifier #{verifier.nil? ? 'not present':'invalid'} #{res_final}"
                  end
                end
              end
            client_final_resp.errback { |err| response.fail err }
            end
          end
        end
        client_first_resp.errback {
            |err| response.fail err }
        return response
      end


      # MongoDB handles the end of authentication different than in RFC 5802
      # it needs at least an additional empty response (this needs to be iterated until res[done]=true 
      #  (at least it is done so in the official mongo-ruby-drive (at least it is done so in the official mongo-ruby-driver))
      #   -> recursion (is technically more loop than recursion but here it's one)
      # 
      # @param response [EM::Mongo::ResponseRequest] to fail or succeed after completion
      # @param conv_id   ConversationId to send to the server on each iteration
    def handle_server_end(response,conv_id) # will set the response
      client_end = BSON::Binary.new('')
      client_end_msg = CLIENT_EMPTY_MESSAGE.merge(PAYLOAD=>client_end, conversationId:conv_id)
      server_end_resp = @db.collection(SYSTEM_COMMAND_COLLECTION).first(client_end_msg)
      
      server_end_resp.errback{|err| response.fail err}
      
      server_end_resp.callback do |res|
        if not is_server_response_valid? res
          response.fail "got invalid response on handling server_end: #{res.nil? ? 'nil' : res}"
        else
         if res['done'] == true || res['done'] == 'true'
           response.succeed true
         else
           handle_server_end(response,conv_id) # try it again
         end
        end
      end
    end
      
      # to be valid the response has to
      #  * be not nil
      #  * contain at least ['done'], ['ok'], ['payload'], ['conversationId']
      #  * ['ok'].to_i has to be 1
      #  * ['conversationId'] has to match the first sent one
      # @param [BSON::OrderedHash] response the response got from server
    def is_server_response_valid?(response)
      if response.nil? then return false; end
      if response['done'].nil?    or
         response['ok'].nil?      or
         response['payload'].nil? or
         response['conversationId'].nil? then
        return false;
      end
      
      if not Support.ok? response then return false; end
      if not @conversation_id.nil? and response['conversationId'] != @conversation_id
        return false;
      end
      
      true
    end
      
      ## verify the verifier (v=...)
    def verifier_valid?(verifier)
      verifier == server_signature
    end


   ### Building blocks
     # @see http://tools.ietf.org/html/rfc5802#section-2.2

    def hi(password, salt, iterations)
      OpenSSL::PKCS5.pbkdf2_hmac_sha1(
        password,
        Base64.strict_decode64(salt),
        iterations,
        DIGEST.size
       )
    end

    def hmac(data,key)
      OpenSSL::HMAC.digest(DIGEST, data, key)
    end

    # xor for strings
    def xor(first, second)
      first.bytes
        .zip(second.bytes)
        .map{|(x,y)| (x ^ y).chr}
        .join('')
    end


    def client_nonce
        @client_nonce ||= SecureRandom.base64
    end

    # needs @username, @plain_password defined
    def hashed_password
      @hashed_password ||= Support.hash_password(@username, @plain_password).encode("UTF-8")
    end

    #needs @username, @plain_password, @salt, @iterations defined
    def salted_password
      @salted_password ||= hi(hashed_password, @salt, @iterations)
    end
    
    # @see http://tools.ietf.org/html/rfc5802#section-3
    def client_key 
      @client_key ||= hmac(salted_password,CLIENT_KEY)
    end
      # server_key = hmac(salted_password,"Server Key")
    def server_key
      @server_key ||= hmac(salted_password,SERVER_KEY)
    end

    #needs @username, @plain_password, @salt, @iterations, @auth_message defined
    def client_signature
      @client_signature ||= hmac(DIGEST.digest(client_key), @auth_message)
    end

    # server_signature = B64(hmac(server_key, auth_message)
   def server_signature
     @server_signature ||= Base64.strict_encode64(hmac(server_key, @auth_message))
   end

    class FirstMessage
      include EM::Deferrable

    end
  end
end