1 /**
2 	SASL authentication functions
3 
4 	Copyright: © 2012-2016 Nicolas Gurrola
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 	Authors: Nicolas Gurrola
7 */
8 module vibe.db.mongo.sasl;
9 
10 import std.algorithm;
11 import std.base64;
12 import std.conv;
13 import std.digest.hmac;
14 import std.digest.sha;
15 import std.exception;
16 import std.format;
17 import std.string;
18 import std.traits;
19 import std.utf;
20 import vibe.crypto.cryptorand;
21 
22 @safe:
23 
24 private SHA1HashMixerRNG g_rng()
25 {
26 	static SHA1HashMixerRNG m_rng;
27 	if (!m_rng) m_rng = new SHA1HashMixerRNG;
28 	return m_rng;
29 }
30 
31 package struct ScramState
32 {
33 	@safe:
34 
35 	private string m_firstMessageBare;
36 	private string m_nonce;
37 	private DigestType!SHA1 m_saltedPassword;
38 	private string m_authMessage;
39 
40 	string createInitialRequest(string user)
41 	{
42 		ubyte[18] randomBytes;
43 		g_rng.read(randomBytes[]);
44 		m_nonce = Base64.encode(randomBytes);
45 
46 		m_firstMessageBare = format("n=%s,r=%s", escapeUsername(user), m_nonce);
47 		return format("n,,%s", m_firstMessageBare);
48 	}
49 
50 	version (unittest) private string createInitialRequestWithFixedNonce(string user, string nonce)
51 	{
52 		m_nonce = nonce;
53 
54 		m_firstMessageBare = format("n=%s,r=%s", escapeUsername(user), m_nonce);
55 		return format("n,,%s", m_firstMessageBare);
56 	}
57 
58 	// MongoDB drivers require 4096 min iterations https://github.com/mongodb/specifications/blob/59390a7ab2d5c8f9c29b8af1775ff25915c44036/source/auth/auth.rst#scram-sha-1
59 	string update(string password, string challenge, int minIterations = 4096)
60 	{
61 		string serverFirstMessage = challenge;
62 
63 		string next = challenge.find(',');
64 		if (challenge.length < 2 || challenge[0 .. 2] != "r=" || next.length < 3 || next[1 .. 3] != "s=")
65 			throw new Exception("Invalid server challenge format");
66 		string serverNonce = challenge[2 .. $ - next.length];
67 		challenge = next[3 .. $];
68 		next = challenge.find(',');
69 		ubyte[] salt = Base64.decode(challenge[0 .. $ - next.length]);
70 
71 		if (next.length < 3 || next[1 .. 3] != "i=")
72 			throw new Exception("Invalid server challenge format");
73 		int iterations = next[3 .. $].to!int();
74 
75 		if (iterations < minIterations)
76 			throw new Exception("Server must request at least " ~ minIterations.to!string ~ " iterations");
77 
78 		if (serverNonce[0 .. m_nonce.length] != m_nonce)
79 			throw new Exception("Invalid server nonce received");
80 		string finalMessage = format("c=biws,r=%s", serverNonce);
81 
82 		m_saltedPassword = pbkdf2(password.representation, salt, iterations);
83 		m_authMessage = format("%s,%s,%s", m_firstMessageBare, serverFirstMessage, finalMessage);
84 
85 		auto proof = getClientProof(m_saltedPassword, m_authMessage);
86 		return format("%s,p=%s", finalMessage, Base64.encode(proof));
87 	}
88 
89 	string finalize(string challenge)
90 	{
91 		if (challenge.length < 2 || challenge[0 .. 2] != "v=")
92 		{
93 			throw new Exception("Invalid server signature format");
94 		}
95 		if (!verifyServerSignature(Base64.decode(challenge[2 .. $]), m_saltedPassword, m_authMessage))
96 		{
97 			throw new Exception("Invalid server signature");
98 		}
99 		return null;
100 	}
101 
102 	private static string escapeUsername(string user)
103 	{
104 		char[] buffer;
105 		foreach (i, dchar ch; user)
106 		{
107 			if (ch == ',' || ch == '=') {
108 				if (!buffer) {
109 					buffer.reserve(user.length + 2);
110 					buffer ~= user[0 .. i];
111 				}
112 				if (ch == ',')
113 					buffer ~= "=2C";
114 				else
115 					buffer ~= "=3D";
116 			} else if (buffer)
117 				encode(buffer, ch);
118 		}
119 		return buffer ? () @trusted { return assumeUnique(buffer); } () : user;
120 	}
121 
122 	/// escapeUsername preserves plain usernames unchanged
123 	unittest
124 	{
125 		string user = "user";
126 		assert(escapeUsername(user) == user);
127 		assert(escapeUsername(user) is user);
128 	}
129 
130 	/// escapeUsername encodes commas as =2C
131 	unittest
132 	{
133 		assert(escapeUsername("user,1") == "user=2C1");
134 	}
135 
136 	/// escapeUsername encodes equals as =3D
137 	unittest
138 	{
139 		assert(escapeUsername("user=1") == "user=3D1");
140 	}
141 
142 	/// escapeUsername encodes mixed commas and equals
143 	unittest
144 	{
145 		assert(escapeUsername("u,=ser1") == "u=2C=3Dser1");
146 		assert(escapeUsername("u=se=r1") == "u=3Dse=3Dr1");
147 	}
148 
149 	/// escapeUsername returns empty string for empty input
150 	unittest
151 	{
152 		assert(escapeUsername("") == "");
153 	}
154 
155 	/// escapeUsername encodes strings with only commas
156 	unittest
157 	{
158 		assert(escapeUsername(",,") == "=2C=2C");
159 	}
160 
161 	/// escapeUsername encodes strings with only equals
162 	unittest
163 	{
164 		assert(escapeUsername("==") == "=3D=3D");
165 	}
166 
167 	/// escapeUsername returns identity for plain alphanumeric strings
168 	unittest
169 	{
170 		assert(escapeUsername("plainuser123") == "plainuser123");
171 		assert(escapeUsername("plainuser123") is "plainuser123");
172 	}
173 
174 	private static auto getClientProof(DigestType!SHA1 saltedPassword, string authMessage)
175 	{
176 		auto clientKey = () @trusted { return hmac!SHA1("Client Key".representation, saltedPassword); } ();
177 		auto storedKey = sha1Of(clientKey);
178 		auto clientSignature = () @trusted { return hmac!SHA1(authMessage.representation, storedKey); } ();
179 
180 		foreach (i; 0 .. clientKey.length)
181 		{
182 			clientKey[i] = clientKey[i] ^ clientSignature[i];
183 		}
184 		return clientKey;
185 	}
186 
187 	private static bool verifyServerSignature(ubyte[] signature, DigestType!SHA1 saltedPassword, string authMessage)
188 	@trusted {
189 		auto serverKey = hmac!SHA1("Server Key".representation, saltedPassword);
190 		auto serverSignature = hmac!SHA1(authMessage.representation, serverKey);
191 		return serverSignature == signature;
192 	}
193 }
194 
195 private DigestType!SHA1 pbkdf2(const ubyte[] password, const ubyte[] salt, int iterations)
196 {
197 	import std.bitmanip;
198 
199 	ubyte[4] intBytes = [0, 0, 0, 1];
200 	auto last = () @trusted { return hmac!SHA1(salt, intBytes[], password); } ();
201 	static assert(isStaticArray!(typeof(last)),
202 		"Code is written so that the hash array is expected to be placed on the stack");
203 	auto current = last;
204 	foreach (i; 1 .. iterations)
205 	{
206 		last = () @trusted { return hmac!SHA1(last[], password); } ();
207 		foreach (j; 0 .. current.length)
208 		{
209 			current[j] = current[j] ^ last[j];
210 		}
211 	}
212 	return current;
213 }
214 
215 /// SCRAM-SHA-1 full authentication flow using MongoDB spec test vectors
216 unittest
217 {
218 	import vibe.db.mongo.settings : MongoClientSettings;
219 
220 	ScramState state;
221 	assert(state.createInitialRequestWithFixedNonce("user", "fyko+d2lbbFgONRv9qkxdawL")
222 		== "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL");
223 	auto last = state.update(MongoClientSettings.makeDigest("user", "pencil"),
224 		"r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000");
225 	assert(last == "c=biws,r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,p=MC2T8BvbmWRckDw8oWl5IVghwCY=",
226 		last);
227 	last = state.finalize("v=UMWeI25JD1yNYZRMpZ4VHvhZ9e0=");
228 	assert(last == "", last);
229 }
230 
231 /// SCRAM update throws on missing r= prefix in server challenge
232 unittest
233 {
234 	import std.exception : assertThrown;
235 
236 	ScramState s;
237 	s.createInitialRequestWithFixedNonce("user", "testnonce");
238 	assertThrown!Exception(s.update("digest", "invalid_challenge"));
239 }
240 
241 /// SCRAM update throws on missing s= field in server challenge
242 unittest
243 {
244 	import std.exception : assertThrown;
245 
246 	ScramState s;
247 	s.createInitialRequestWithFixedNonce("user", "testnonce");
248 	assertThrown!Exception(s.update("digest", "r=testnonceServer,x=bad"));
249 }
250 
251 /// SCRAM update throws when server nonce doesn't start with client nonce
252 unittest
253 {
254 	import std.exception : assertThrown;
255 
256 	ScramState s;
257 	s.createInitialRequestWithFixedNonce("user", "testnonce");
258 	assertThrown!Exception(s.update("digest",
259 		"r=WRONGnonceServer,s=QSXCR+Q6sek8bf92,i=4096"));
260 }
261 
262 /// SCRAM update throws when iteration count 4095 is below minimum 4096
263 unittest
264 {
265 	import std.exception : assertThrown;
266 
267 	ScramState s;
268 	s.createInitialRequestWithFixedNonce("user", "testnonce");
269 	assertThrown!Exception(s.update("digest",
270 		"r=testnonceServer,s=QSXCR+Q6sek8bf92,i=4095"));
271 }
272 
273 /// SCRAM update succeeds when iteration count is exactly minimum 4096
274 unittest
275 {
276 	import vibe.db.mongo.settings : MongoClientSettings;
277 
278 	ScramState s;
279 	s.createInitialRequestWithFixedNonce("user", "testnonce");
280 	auto digest = MongoClientSettings.makeDigest("user", "pencil");
281 	s.update(digest, "r=testnonceServer,s=QSXCR+Q6sek8bf92,i=4096");
282 }
283 
284 // Test vectors from the MongoDB SCRAM-SHA-1 specification:
285 // https://github.com/mongodb/specifications/blob/59390a7ab2d5c8f9c29b8af1775ff25915c44036/source/auth/auth.rst#id5
286 // Nonce, salt, iteration count, and server response are all from that spec.
287 version (unittest)
288 {
289 	private enum scramTestNonce = "fyko+d2lbbFgONRv9qkxdawL";
290 	private enum scramTestChallenge =
291 		"r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000";
292 
293 	private ScramState createScramStateForFinalize()
294 	{
295 		import vibe.db.mongo.settings : MongoClientSettings;
296 
297 		ScramState s;
298 		s.createInitialRequestWithFixedNonce("user", scramTestNonce);
299 		s.update(MongoClientSettings.makeDigest("user", "pencil"), scramTestChallenge);
300 		return s;
301 	}
302 }
303 
304 /// SCRAM finalize throws when response doesn't start with "v="
305 unittest
306 {
307 	import std.exception : assertThrown;
308 
309 	auto s = createScramStateForFinalize();
310 	assertThrown!Exception(s.finalize("invalid_format"));
311 }
312 
313 /// SCRAM finalize throws on wrong server signature value
314 unittest
315 {
316 	import std.exception : assertThrown;
317 
318 	auto s = createScramStateForFinalize();
319 	assertThrown!Exception(s.finalize("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
320 }
321 
322 /// SCRAM finalize throws when response is too short
323 unittest
324 {
325 	import std.exception : assertThrown;
326 
327 	auto s = createScramStateForFinalize();
328 	assertThrown!Exception(s.finalize("v"));
329 }