From 964a7fa95f4efc544c30a0aae289a6c4f476e995 Mon Sep 17 00:00:00 2001 From: Cristian Cepeda Date: Sat, 30 Mar 2019 21:39:46 -0500 Subject: [PATCH] WIP Adds support to enable SCRAM Authentication --- lib/moped/node.rb | 26 ++- lib/moped/protocol.rb | 3 + lib/moped/protocol/commands.rb | 1 + .../protocol/commands/scram_authenticate.rb | 179 ++++++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 lib/moped/protocol/commands/scram_authenticate.rb diff --git a/lib/moped/node.rb b/lib/moped/node.rb index 56b234b..cbf2bb5 100644 --- a/lib/moped/node.rb +++ b/lib/moped/node.rb @@ -498,12 +498,28 @@ def login(database, username, password) getnonce = Protocol::Command.new(database, getnonce: 1) connection.write [getnonce] result = connection.read.documents.first - raise Errors::OperationFailure.new(getnonce, result) unless result["ok"] == 1 - authenticate = Protocol::Commands::Authenticate.new(database, username, password, result["nonce"]) - connection.write [authenticate] - result = connection.read.documents.first + raise Errors::OperationFailure.new(getnonce, result) unless result[Protocol::OK] == 1 + + if options[:auth_mech] == :scram + authenticate = Protocol::Commands::ScramAuthenticate.new(database, username, password) + + connection.write [authenticate.start(result)] + result = connection.read.documents.first + + connection.write [authenticate.continue(result)] + result = connection.read.documents.first + + until result[Protocol::DONE] + connection.write [authenticate.finalize(result)] + result = connection.read.documents.first + end + else + authenticate = Protocol::Commands::Authenticate.new(database, username, password, result[Protocol::NONCE]) + connection.write [authenticate] + result = connection.read.documents.first + end - unless result["ok"] == 1 + unless result[Protocol::OK] == 1 # See if we had connectivity issues so we can retry e = Errors::PotentialReconfiguration.new(authenticate, result) if e.reconfiguring_replica_set? diff --git a/lib/moped/protocol.rb b/lib/moped/protocol.rb index 4a43547..70e2513 100644 --- a/lib/moped/protocol.rb +++ b/lib/moped/protocol.rb @@ -3,6 +3,9 @@ module Moped #:nodoc: # The +Moped::Protocol+ namespace contains convenience classes for # building all of the possible messages defined in the Mongo Wire Protocol. module Protocol + DONE = 'done'.freeze + NONCE = 'nonce'.freeze + OK = 'ok'.freeze end end diff --git a/lib/moped/protocol/commands.rb b/lib/moped/protocol/commands.rb index 40a0432..525eb68 100644 --- a/lib/moped/protocol/commands.rb +++ b/lib/moped/protocol/commands.rb @@ -9,3 +9,4 @@ module Commands end require "moped/protocol/commands/authenticate" +require "moped/protocol/commands/scram_authenticate" diff --git a/lib/moped/protocol/commands/scram_authenticate.rb b/lib/moped/protocol/commands/scram_authenticate.rb new file mode 100644 index 0000000..c0e4c86 --- /dev/null +++ b/lib/moped/protocol/commands/scram_authenticate.rb @@ -0,0 +1,179 @@ +module Moped + module Protocol + module Commands + class ScramAuthenticate + SCRAM_SHA_1_MECHANISM = 'SCRAM-SHA-1'.freeze + CLIENT_CONTINUE_MESSAGE = { saslContinue: 1 }.freeze + CLIENT_FIRST_MESSAGE = { saslStart: 1, autoAuthorize: 1 }.freeze + CLIENT_KEY = 'Client Key'.freeze + ID = 'conversationId'.freeze + ITERATIONS = /i=(\d+)/.freeze + MIN_ITER_COUNT = 4096 + PAYLOAD = 'payload'.freeze + RNONCE = /r=([^,]*)/.freeze + SALT = /s=([^,]*)/.freeze + SERVER_KEY = 'Server Key'.freeze + VERIFIER = /v=([^,]*)/.freeze + + attr_reader \ + :database, + :username, + :password, + :nonce, + :result + + def initialize(database, username, password) + @database = database + @username = username + @password = password + end + + def start(result) + @nonce = result[Protocol::NONCE] + Protocol::Command.new(database, { + saslStart: 1, + autoAuthorize: 1, + payload: client_first_message, + mechanism: SCRAM_SHA_1_MECHANISM + }) + end + + def continue(result) + validate_first_message!(result) + salted_password + + Protocol::Command.new(database, { + saslContinue: 1, + payload: client_final_message, + conversationId: result[ID] + }) + end + + def finalize(result) + Protocol::Command.new( + database, + CLIENT_CONTINUE_MESSAGE.merge( + payload: client_empty_message, + conversationId: result[ID] + ) + ) + end + + private + + def client_empty_message + BSON::Binary.new(:md5, '') + end + + def hmac(data, key) + OpenSSL::HMAC.digest(digest, data, key) + end + + def xor(first, second) + first.bytes.zip(second.bytes).map{ |(a,b)| (a ^ b).chr }.join('') + end + + def validate_first_message!(result) + validate!(result) + # raise Error::InvalidNonce.new(nonce, rnonce) unless rnonce.start_with?(nonce) + end + + def client_key + @client_key ||= hmac(salted_password, CLIENT_KEY) + end + + def client_proof(key, signature) + @client_proof ||= Base64.strict_encode64(xor(key, signature)) + end + + def client_final + @client_final ||= client_proof(client_key, client_signature(stored_key(client_key), auth_message)) + end + + def auth_message + @auth_message ||= "#{first_bare},#{result[PAYLOAD].data},#{without_proof}" + end + + def stored_key(key) + h(key) + end + + def h(string) + digest.digest(string) + end + + def client_signature(key, message) + @client_signature ||= hmac(key, message) + end + + def without_proof + @without_proof ||= "c=biws,r=#{rnonce}" + end + + def client_final_message + BSON::Binary.new(:md5, "#{without_proof},p=#{client_final}") + end + + def rnonce + @rnonce ||= payload_data.match(RNONCE)[1] + end + + def validate!(result) + if result[Protocol::OK] != 1 + # raise Unauthorized.new(user, full_mechanism) + end + @result = result + end + + def payload_data + result[PAYLOAD].data + end + + def iterations + @iterations ||= payload_data.match(ITERATIONS)[1].to_i.tap do |i| + if i < MIN_ITER_COUNT + # raise Error::InsufficientIterationCount.new( + # Error::InsufficientIterationCount.message(MIN_ITER_COUNT, i)) + end + end + end + + def hi(data) + OpenSSL::PKCS5.pbkdf2_hmac_sha1( + data, + Base64.strict_decode64(salt), + iterations, + digest.size + ) + end + + def salt + @salt ||= payload_data.match(SALT)[1] + end + + def digest + @digest ||= OpenSSL::Digest::SHA1.new.freeze + end + + def salted_password + hi(hashed_password) + end + + def hashed_password + # unless password + # raise Error::MissingPassword + # end + @hashed_password ||= Digest::MD5.hexdigest("#{username}:mongo:#{password}").encode('utf-8') + end + + def client_first_message + BSON::Binary.new(:md5, "n,,#{first_bare}") + end + + def first_bare + @first_bare ||= "n=#{username.encode('utf-8').gsub('=','=3D').gsub(',','=2C')},r=#{nonce}" + end + end + end + end +end