Skip to content
This repository was archived by the owner on Jan 15, 2024. It is now read-only.

Commit 55f11c6

Browse files
author
Cristian Cepeda
committed
Adds support for SCRAM Authentication
1 parent 32cba17 commit 55f11c6

File tree

5 files changed

+235
-5
lines changed

5 files changed

+235
-5
lines changed

lib/moped/errors.rb

+28
Original file line numberDiff line numberDiff line change
@@ -143,5 +143,33 @@ class ReplicaSetReconfigured < DoNotDisconnect; end
143143

144144
# Tag applied to unhandled exceptions on a node.
145145
module SocketError; end
146+
147+
class InsufficientIterationCount < StandardError
148+
149+
def initialize(msg)
150+
super(msg)
151+
end
152+
153+
def self.message(required_count, given_count)
154+
"This auth mechanism requires an iteration count of #{required_count}, but the server only requested #{given_count}"
155+
end
156+
end
157+
158+
class MissingPassword < StandardError
159+
def initialize(msg = nil)
160+
super(msg || 'There are no password configured')
161+
end
162+
end
163+
164+
class InvalidNonce < StandardError
165+
attr_reader :nonce
166+
attr_reader :rnonce
167+
168+
def initialize(nonce, rnonce)
169+
@nonce = nonce
170+
@rnonce = rnonce
171+
super("Expected server rnonce '#{rnonce}' to start with client nonce '#{nonce}'.")
172+
end
173+
end
146174
end
147175
end

lib/moped/node.rb

+21-5
Original file line numberDiff line numberDiff line change
@@ -498,12 +498,28 @@ def login(database, username, password)
498498
getnonce = Protocol::Command.new(database, getnonce: 1)
499499
connection.write [getnonce]
500500
result = connection.read.documents.first
501-
raise Errors::OperationFailure.new(getnonce, result) unless result["ok"] == 1
502-
authenticate = Protocol::Commands::Authenticate.new(database, username, password, result["nonce"])
503-
connection.write [authenticate]
504-
result = connection.read.documents.first
501+
raise Errors::OperationFailure.new(getnonce, result) unless result[Protocol::OK] == 1
502+
503+
if options[:auth_mech] == :scram
504+
authenticate = Protocol::Commands::ScramAuthenticate.new(database, username, password)
505+
506+
connection.write [authenticate.start(result)]
507+
result = connection.read.documents.first
508+
509+
connection.write [authenticate.continue(result)]
510+
result = connection.read.documents.first
511+
512+
until result[Protocol::DONE]
513+
connection.write [authenticate.finalize(result)]
514+
result = connection.read.documents.first
515+
end
516+
else
517+
authenticate = Protocol::Commands::Authenticate.new(database, username, password, result[Protocol::NONCE])
518+
connection.write [authenticate]
519+
result = connection.read.documents.first
520+
end
505521

506-
unless result["ok"] == 1
522+
unless result[Protocol::OK] == 1
507523
# See if we had connectivity issues so we can retry
508524
e = Errors::PotentialReconfiguration.new(authenticate, result)
509525
if e.reconfiguring_replica_set?

lib/moped/protocol.rb

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ module Moped #:nodoc:
33
# The +Moped::Protocol+ namespace contains convenience classes for
44
# building all of the possible messages defined in the Mongo Wire Protocol.
55
module Protocol
6+
DONE = 'done'.freeze
7+
NONCE = 'nonce'.freeze
8+
OK = 'ok'.freeze
69
end
710
end
811

lib/moped/protocol/commands.rb

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ module Commands
99
end
1010

1111
require "moped/protocol/commands/authenticate"
12+
require "moped/protocol/commands/scram_authenticate"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
module Moped
2+
module Protocol
3+
module Commands
4+
class ScramAuthenticate
5+
SCRAM_SHA_1_MECHANISM = 'SCRAM-SHA-1'.freeze
6+
CLIENT_CONTINUE_MESSAGE = { saslContinue: 1 }.freeze
7+
CLIENT_FIRST_MESSAGE = { saslStart: 1, autoAuthorize: 1 }.freeze
8+
CLIENT_KEY = 'Client Key'.freeze
9+
ID = 'conversationId'.freeze
10+
ITERATIONS = /i=(\d+)/.freeze
11+
MIN_ITER_COUNT = 4096
12+
PAYLOAD = 'payload'.freeze
13+
RNONCE = /r=([^,]*)/.freeze
14+
SALT = /s=([^,]*)/.freeze
15+
SERVER_KEY = 'Server Key'.freeze
16+
VERIFIER = /v=([^,]*)/.freeze
17+
18+
attr_reader \
19+
:database,
20+
:username,
21+
:password,
22+
:nonce,
23+
:result
24+
25+
def initialize(database, username, password)
26+
@database = database
27+
@username = username
28+
@password = password
29+
end
30+
31+
def start(result)
32+
@nonce = result[Protocol::NONCE]
33+
Protocol::Command.new(database, {
34+
saslStart: 1,
35+
autoAuthorize: 1,
36+
payload: client_first_message,
37+
mechanism: SCRAM_SHA_1_MECHANISM
38+
})
39+
end
40+
41+
def continue(result)
42+
validate_first_message!(result)
43+
salted_password
44+
45+
Protocol::Command.new(database, {
46+
saslContinue: 1,
47+
payload: client_final_message,
48+
conversationId: result[ID]
49+
})
50+
end
51+
52+
def finalize(result)
53+
Protocol::Command.new(
54+
database,
55+
CLIENT_CONTINUE_MESSAGE.merge(
56+
payload: client_empty_message,
57+
conversationId: result[ID]
58+
)
59+
)
60+
end
61+
62+
private
63+
64+
def client_empty_message
65+
BSON::Binary.new(:md5, '')
66+
end
67+
68+
def hmac(data, key)
69+
OpenSSL::HMAC.digest(digest, data, key)
70+
end
71+
72+
def xor(first, second)
73+
first.bytes.zip(second.bytes).map{ |(a,b)| (a ^ b).chr }.join('')
74+
end
75+
76+
def validate_first_message!(result)
77+
validate!(result)
78+
raise Errors::InvalidNonce.new(nonce, rnonce) unless rnonce.start_with?(nonce)
79+
end
80+
81+
def client_key
82+
@client_key ||= hmac(salted_password, CLIENT_KEY)
83+
end
84+
85+
def client_proof(key, signature)
86+
@client_proof ||= Base64.strict_encode64(xor(key, signature))
87+
end
88+
89+
def client_final
90+
@client_final ||= client_proof(client_key, client_signature(stored_key(client_key), auth_message))
91+
end
92+
93+
def auth_message
94+
@auth_message ||= "#{first_bare},#{result[PAYLOAD].data},#{without_proof}"
95+
end
96+
97+
def stored_key(key)
98+
h(key)
99+
end
100+
101+
def h(string)
102+
digest.digest(string)
103+
end
104+
105+
def client_signature(key, message)
106+
@client_signature ||= hmac(key, message)
107+
end
108+
109+
def without_proof
110+
@without_proof ||= "c=biws,r=#{rnonce}"
111+
end
112+
113+
def client_final_message
114+
BSON::Binary.new(:md5, "#{without_proof},p=#{client_final}")
115+
end
116+
117+
def rnonce
118+
@rnonce ||= payload_data.match(RNONCE)[1]
119+
end
120+
121+
def validate!(result)
122+
if result[Protocol::OK] != 1
123+
raise Errors::AuthenticationFailure.new(
124+
'scram.start',
125+
{ "err" => "Invalid result ok = #{result[Protocol::OK]}" }
126+
)
127+
end
128+
@result = result
129+
end
130+
131+
def payload_data
132+
result[PAYLOAD].data
133+
end
134+
135+
def iterations
136+
@iterations ||= payload_data.match(ITERATIONS)[1].to_i.tap do |i|
137+
if i < MIN_ITER_COUNT
138+
raise Errors::InsufficientIterationCount.new(
139+
Errors::InsufficientIterationCount.message(MIN_ITER_COUNT, i))
140+
end
141+
end
142+
end
143+
144+
def hi(data)
145+
OpenSSL::PKCS5.pbkdf2_hmac_sha1(
146+
data,
147+
Base64.strict_decode64(salt),
148+
iterations,
149+
digest.size
150+
)
151+
end
152+
153+
def salt
154+
@salt ||= payload_data.match(SALT)[1]
155+
end
156+
157+
def digest
158+
@digest ||= OpenSSL::Digest::SHA1.new.freeze
159+
end
160+
161+
def salted_password
162+
hi(hashed_password)
163+
end
164+
165+
def hashed_password
166+
unless password
167+
raise Errors::MissingPassword
168+
end
169+
@hashed_password ||= Digest::MD5.hexdigest("#{username}:mongo:#{password}").encode('utf-8')
170+
end
171+
172+
def client_first_message
173+
BSON::Binary.new(:md5, "n,,#{first_bare}")
174+
end
175+
176+
def first_bare
177+
@first_bare ||= "n=#{username.encode('utf-8').gsub('=','=3D').gsub(',','=2C')},r=#{nonce}"
178+
end
179+
end
180+
end
181+
end
182+
end

0 commit comments

Comments
 (0)