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 }