Class: EM::Mongo::SCRAM

Inherits:
Authentication show all
Defined in:
lib/em-mongo/auth/scram.rb

Overview

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

Defined Under Namespace

Classes: FirstMessage

Constant Summary collapse

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

Constants inherited from Authentication

Authentication::SYSTEM_COMMAND_COLLECTION

Instance Method Summary collapse

Methods inherited from Authentication

#initialize

Constructor Details

This class inherits a constructor from EM::Mongo::Authentication

Instance Method Details

#authenticate(username, password) ⇒ EM::Mongo::RequestResponse

Returns Calls back with true or false, indicating success or failure.

Parameters:

Returns:

Raises:



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
# File 'lib/em-mongo/auth/scram.rb', line 40

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

#client_keyObject



214
215
216
# File 'lib/em-mongo/auth/scram.rb', line 214

def client_key 
  @client_key ||= hmac(salted_password,CLIENT_KEY)
end

#client_nonceObject



199
200
201
# File 'lib/em-mongo/auth/scram.rb', line 199

def client_nonce
    @client_nonce ||= SecureRandom.base64
end

#client_signatureObject

needs @username, @plain_password, @salt, @iterations, @auth_message defined



223
224
225
# File 'lib/em-mongo/auth/scram.rb', line 223

def client_signature
  @client_signature ||= hmac(DIGEST.digest(client_key), @auth_message)
end

#handle_server_end(response, conv_id) ⇒ Object

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=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)

Parameters:

  • response (EM::Mongo::ResponseRequest)

    to fail or succeed after completion

  • conv_id

    ConversationId to send to the server on each iteration



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/em-mongo/auth/scram.rb', line 125

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

#hashed_passwordObject

needs @username, @plain_password defined



204
205
206
# File 'lib/em-mongo/auth/scram.rb', line 204

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

#hi(password, salt, iterations) ⇒ Object



177
178
179
180
181
182
183
184
# File 'lib/em-mongo/auth/scram.rb', line 177

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

#hmac(data, key) ⇒ Object



186
187
188
# File 'lib/em-mongo/auth/scram.rb', line 186

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

#is_server_response_valid?(response) ⇒ Boolean

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

Parameters:

  • response (BSON::OrderedHash)

    the response got from server

Returns:

  • (Boolean)


151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/em-mongo/auth/scram.rb', line 151

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

#salted_passwordObject

needs @username, @plain_password, @salt, @iterations defined



209
210
211
# File 'lib/em-mongo/auth/scram.rb', line 209

def salted_password
  @salted_password ||= hi(hashed_password, @salt, @iterations)
end

#server_keyObject

server_key = hmac(salted_password,“Server Key”)



218
219
220
# File 'lib/em-mongo/auth/scram.rb', line 218

def server_key
  @server_key ||= hmac(salted_password,SERVER_KEY)
end

#server_signatureObject

server_signature = B64(hmac(server_key, auth_message)



228
229
230
# File 'lib/em-mongo/auth/scram.rb', line 228

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

#verifier_valid?(verifier) ⇒ Boolean

verify the verifier (v=…)

Returns:

  • (Boolean)


169
170
171
# File 'lib/em-mongo/auth/scram.rb', line 169

def verifier_valid?(verifier)
  verifier == server_signature
end

#xor(first, second) ⇒ Object

xor for strings



191
192
193
194
195
196
# File 'lib/em-mongo/auth/scram.rb', line 191

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