I recently implemented SCRAM-SHA-1 over SASL for Fantom's MongoDB driver so it could authenticate against MongoDB v3 databases.

Much to my surprise, for such a massive breaking change to MongoDB drivers, there's next to nothing available that succinctly explains how it works. Not a sausage!

What I did find though, was a list of specifications and documentation that was as cryptic as the authentication mechanism itself!

By scrutinising every word in the documentation, and by reading the source code for a native Erlang driver, I was finally able to figure it out.

So now I present to you, what would have been extremely helpful to me, a fully worked authentication conversation with MongoDB for SCRAM-SHA-1 over SASL.

Overview

All the authentication documentation for MongoDB v3.x talks of SCRAM-SHA-1, or Salted Challenge Response Authentication Mechanism (SCRAM) with SHA-1. SCRAM defines how to encode an authentication message to send to the server. It uses the PBKDF2 algorithm from the Public-Key Cryptography Standards (PKCS).

SASL, or Simple Authentication and Security Layer, then defines a protocol of how to send / receive these authentication messages.

These SASL messages are then wrapped up in MongoDB BSON documents and sent as database commands in the usual MongoDB driver manner.

If you want the low down on all of the above, here are links to the relevant parts of the specifications:

Note that the MongoDB specification above was bloody hard to track down! It does have a sample client / server conversation, but it makes no attempt to explain how it calculated at the given values. For consistency, this example uses the same the parameters, but shows how they were calculated.

The example conversation assumes the following values:

  • Username: user
  • Password: pencil
  • Database: test

Note that all code listed here is written in Fantom.

Step 1

Send an authentication request to the server.

In the request we name the user we wish to authenticate as, and a nonce. The nonce is random sequence of characters and should be of cryptographic strength.

clientNonce    := ... crypto strength random characters ...
// --> fyko+d2lbbFgONRv9qkxdawL

clientFirstMsg := "n=${userName},r=${clientNonce}"
// --> n=user,r=fyko+d2lbbFgONRv9qkxdawL

Send a command to the test database ( test.$cmd ) consisting of the following BSON document. Note that n,, is constant and in some literature is referred to as GS2 Header.

{
    "saslStart"     : 1,
    "mechanism"     : "SCRAM-SHA-1",
    "payload"       : binary("n,," + clientFirstMsg),
    "autoAuthorize" : 1
}

In all these messages, the payload field is a BSON binary object, but the content is always just ASCII characters.

The server should respond with the following BSON document:

{
    "conversationId" : 1,
    "payload"        : binary("r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000"),
    "done"           : false,
    "ok"             : 1
}

From which we can derive the following variables:

conversationId   := 1
serverFirstMsg   := "r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000"
serverNonce      := "fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE"
serverSalt       := "rQ9ZY3MntBeuP3E1TDVC4w=="
serverIterations := 10000

Step 2

Send an encoded message that proves we have the password. This is hard part.

First we create a string hash of the normalised password.

hashedPassword  := Buf().print("${userName}:mongo:${password}").toDigest("MD5").toHex
// --> "1c33006ec1ffd90f9cadcbcc0e118200"

Next we do the cryptographic stuff. Most languages have classes or libraries for doing this. Fantom uses Java's javax.crypto.spec.PBEKeySpec.

dkLen           := 20   // the size of a SHA-1 hash
saltedPassword  := Buf.pbk("PBKDF2WithHmacSHA1", hashedPassword, Buf.fromBase64(serverSalt), serverIterations, dkLen)
// --> 6a bd 37 85 0d a6 e3 27 df 8d c5 af d4 30 79 10 52 f9 24 99

In the next string, clientFinalNoPf (client final no proof), note that biws is a constant and is the just the string "n,," Base64 encoded. In some literature this is referred to as the GS2 Header.

The string "Client Key" is also constant and is used as a default message to be hashed by the (salted) password.

select all
clientFinalNoPf := "c=biws,r=${serverNonce}"
// --> "c=biws,r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE"

authMessage     := "${clientFirstMsg},${serverFirstMsg},${clientFinalNoPf}"
// --> "n=user,r=fyko+d2lbbFgONRv9qkxdawL,r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000,c=biws,r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE"

clientKey       := Buf().print("Client Key").hmac("SHA-1", saltedPassword)
// --> 6e ca 60 b8 b0 46 77 1f c7 17 40 92 de 6e 7e 83 78 59 b3 56

storedKey       := clientKey.toDigest("SHA-1")
// --> a7 9c fa 9f b5 2d a9 ff a9 2c 19 1a 78 99 38 4f 77 81 38 e0

clientSignature := Buf().print(authMessage).hmac("SHA-1", storedKey)
// --> 5e e7 f3 48 ab 9d ee 7b 9b 87 7c ae 7f 07 07 a2 20 78 73 70

clientProof     := xor(clientKey, clientSignature)
// --> 30 2d 93 f0 1b db 99 64 5c 90 3c 3c a1 69 79 21 58 21 c0 26

clientFinal     := "${clientFinalNoPf},p=${clientProof.toBase64}"
// --> "c=biws,r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,p=MC2T8BvbmWRckDw8oWl5IVghwCY="

The xor() method is an annoying little thing where each byte of each input have to be xored together. It is not a native Fantom function, but instead a method you have to write yourself.

It's the clientFinal string that we send as the payload in the next message to the server.

{
    "saslContinue"   : 1,
    "conversationId" : conversationId,
    "payload"        : binary(clientFinal)
}

The server should then respond with:

{
    "conversationId" : 1,
    "payload"        : binary("v=UMWeI25JD1yNYZRMpZ4VHvhZ9e0="),
    "done"           : false,
    "ok"             : 1
}

Now it is our chance to validate the server and check that it also knows the user's password.

Note the string "Server Key" is constant and is used as a default message to be hashed by the (salted) password.

serverKey       := Buf().print("Server Key").hmac("SHA-1", saltedPassword)
// --> 95 1a d5 1f 2a 8c 5f e3 8e a8 6b e9 72 fb fd 6a 79 40 f0 84

serverSignature := Buf().print(authMessage).hmac("SHA-1", serverKey).toBase64
// --> "UMWeI25JD1yNYZRMpZ4VHvhZ9e0="

Note that serverSignature should equal the value sent by the server.

Step 3

Acknowledge and end the conversation.

Next we send a quick message to the server to finish up. Note that payload is an empty binary object.

{
    "saslContinue"   : 1,
    "conversationId" : conversationId,
    "payload"        : binary()
}

To which the server should respond with:

{
    "conversationId" : 1,
    "payload"        : binary(),
    "done"           : true,
    "ok"             : 1
}

Note that done is now true and that signals the end of the authentication conversation.

Edits

  • 1 November 2015 - Original article.
  • 26 May 2018 - Added notes on "Client Key" and "Server Key" being constant.

Discuss