I recently implemented SCRAM (SHA-256) over SASL for SkySpark v3 so I could use the REST API.
SkySpark v3 sports a new authentication algorithm that must be used to communicate with it. But alas there is not much available that succinctly explains how it works.
What I did find though, was a bucket load of specifications and documentation that were as cryptic as the authentication mechanism itself!
So now I present to you, what would have been extremely helpful to me, a fully worked authentication conversation with SkySpark for SCRAM (SHA-256) over SASL.
Overview
All the authentication documentation for SkySpark v3 talks of SCRAM SHA-256
, or Salted Challenge Response Authentication Mechanism (SCRAM) with SHA-256. 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).
Simple Authentication and Security Layer (SASL) then defines a protocol of how to send / receive these authentication messages.
These SASL messages are then wrapped up in HTTP headers and the HTTP request is sent as usual.
If you want the low down on all of the above, here are links to the relevant parts of the specifications:
- Project HayStack: Authentication
- SkySpark Authentication
- RFC5802 - Salted Challenge Response Authentication Mechanism (SCRAM) - Sec. 3 Algorithm Overview
- RFC4422 - Simple Authentication and Security Layer (SASL) - Sec. 3 The Authentication Exchange
- RFC2898 - Password-Based Cryptography Specification - Sec. 5.2 PBKDF2
- RFC7804: Salted Challenge Response HTTP Authentication Mechanism
- RFC7615: HTTP Authentication-Info and Proxy-Authentication-Info Response Header Fields
The Project Haystack and SkySpark documentation do have a sample client / server conversation (the SASL part), but they make no attempt to explain how they calculated the values and the given messages (the SCRAM part)!
Steps:
The following example conversation assumes the following values, which are the same as those used in Project Haystack's authentication documentation:
- Username:
user
- Password:
pencil
Note that all example code listed here is written in Fantom.
1. Hello!
The first request is to initiate the authentication conversation, sending the username we wish to authenticate as.
Note that the URL we authenticate against needs to be a real URL lest we get a 404. It also needs to be one that we're not going to be redirected from. Project specific URLs are fine, but for general purpose usage I find that /ui
works well in all scenarios.
Assuming SkySpark is running locally on port 8080 our first HTTP request looks like:
// Client Request: HelloGET /ui HTTP/1.1 Host: localhost:8080 Authorization: HELLO username=dXNlcg
The username is just our username user
Base64 encoded. But note the lack of trailing =
characters that are usually used for padding. That's because the Authorization
header uses Base64 URIs, which lack the padding.
// Server Response: HelloHTTP/1.1 401 Unauthorized WWW-Authenticate: scram handshakeToken=dXNlcg, hash=SHA-256
SkySpark replies with the above, which tells us we're to use SHA-256
for all our encoding. In general Project Haystack mechanisms this could also be SHA-1
or SHA-512
.
The handshakeToken
is used by the server to keep track of the authentication conversation, similar to a session cookie. So we need to make sure we pass the same value back. (Just ignore the fact it looks like our encoded username - this may change!)
2. First Message
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+d2lbbFgONRv9qkxdawLclientFirstMsg := "n=${userName},r=${clientNonce}"// --> n=user,r=fyko+d2lbbFgONRv9qkxdawL
The first message is then Base64 URI encoded (no trailing =
chars) and sent to the server.
// Client Request: 1st MessageGET /ui HTTP/1.1 Host: localhost:8080 Authorization: SCRAM handshakeToken=dXNlcg, data=bj11c2VyLHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdM
SkySpark would then respond with the following response:
// Server Response: 1st MessageHTTP/1.1 401 Unauthorized WWW-Authenticate: scram handshakeToken=dXNlcg, hash=SHA-256, data=cj1meWtvK2QybGJiRmdPTlJ2OXFreGRhd0xIbytWZ2s3cXZVT0tVd3VXTElXZzRsLzlTcmFHTUhFRSxzPXJROVpZM01udEJldVAzRTFURFZDNHc9PSxpPTEwMDAw
If we Base64 decode the data attribute we get:
r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000
From which we can derive the following variables:
serverFirstMsg := "r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,s=rQ9ZY3MntBeuP3E1TDVC4w==,i=10000" serverNonce := "fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE" serverSalt := "rQ9ZY3MntBeuP3E1TDVC4w==" serverIterations := 10000
3. Second Message
Send an encoded message that proves we have the password. This is hard part.
First we do the cryptographic stuff. Most languages have classes or libraries for doing this. Fantom uses Java's javax.crypto.spec.PBEKeySpec. Because we're using the SHA-256 hashing algorithm, we use PBKDF2WithHmacSHA256
.
dkLen := 32// the size in bytes of a SHA-256 hashsaltedPassword := Buf.pbk("PBKDF2WithHmacSHA256", password, Buf.fromBase64(serverSalt), serverIterations, dkLen)// --> e3 72 cc 2f 65 c6 ac 00 51 e9 e8 de ef 4b ea a3 f0 72 08 a4 6e b2 9f a3 bf 68 ea 76 9e b8 3e e9
dkLen
is constant, but is dependent on which hashing algorithm we're using.
// dkLen ValuesSHA-1 : 20 SHA-256 : 32 SHA-512 : 64
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.
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 := "Client Key".toBuf.hmac("SHA-256", saltedPassword)// --> 26 ac fd 4f 40 f9 5e 8e 74 b2 b3 5f 88 cd 8b e4 35 da 89 db 8d ab cc a8 b9 fb de 34 49 f9 93 b8storedKey := clientKey.toDigest("SHA-256")// --> b6 2f 2a 50 c9 9e 42 27 46 85 5e 9a 60 fa 3c 71 39 f8 78 9a 70 60 46 19 4d ae 5c e8 cf 48 e5 37clientSignature := authMessage.toBuf.hmac("SHA-256", storedKey)// --> 5b 60 ae 4a 75 d8 da 9c 05 3b 85 ef 36 b6 27 df 2c 0a c9 4c f1 65 8f cf 3d 00 e1 1e ee d6 e1 4cclientProof := xor(clientKey, clientSignature)// --> 7d cc 53 05 35 21 84 12 71 89 36 b0 be 7b ac 3b 19 d0 40 97 7c ce 43 67 84 fb 3f 2a a7 2f 72 f4clientFinal := "${clientFinalNoPf},p=${clientProof.toBase64}"// --> "c=biws,r=fyko+d2lbbFgONRv9qkxdawLHo+Vgk7qvUOKUwuWLIWg4l/9SraGMHEE,p=fcxTBTUhhBJxiTawvnusOxnQQJd8zkNnhPs/KqcvcvQ="
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. See Fantom Code for more details.
It is the clientFinal
string that we send to the server in the next message. Like last time, we Base64 URI encode it and send it as the data
attribute of the Authorization
header:
// Client Request: 2nd MessageGET /ui HTTP/1.1 Host: localhost:8080 Authorization: SCRAM handshakeToken=dXNlcg, data=Yz1iaXdzLHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdMSG8rVmdrN3F2VU9LVXd1V0xJV2c0bC85U3JhR01IRUUscD1mY3hUQlRVaGhCSnhpVGF3dm51c094blFRSmQ4emtObmhQcy9LcWN2Y3ZRPQ
SkySpark should then respond with:
// Server Response: 2nd MessageHTTP/1.1 200 Auth successful Authentication-Info: authToken=xxxyyyzzz, hash=SHA-256, data=dj1UenFKVlc4bk5uZ1o5ZzFiL1lXaU84cy9abEhxQkwyb3AxYmxSN0txZG1FPQ
It is the authToken
attribute that we're after. We can use in all subsequent HTTP requests to communicate with SkySpark.
But before we do; how do we know we're communicating with the correct server? How do we know our requests aren't be re-routed to some hackers server? Well now is our chance to validate the server and check that it also knows the user's password.
Base64 decoding the data
attribute gives us:
v=TzqJVW8nNngZ9g1b/YWiO8s/ZlHqBL2op1blR7KqdmE=
If the server truly knows the user's password then we should be able to compute the same value for v
.
Note the string "Server Key"
is constant and is used as a default message to be hashed by the (salted) password.
serverKey := "Server Key".toBuf.hmac("SHA-256", saltedPassword)// --> 5a a1 fd ca 03 cb 46 42 45 ba 1b 94 67 a4 2c 9e 61 47 d6 da 9f cc c9 f2 bf 17 bc 4e ab 2c 1a 75serverSignature := authMessage.toBuf.hmac("SHA-256", serverKey).toBase64// --> "TzqJVW8nNngZ9g1b/YWiO8s/ZlHqBL2op1blR7KqdmE="
Because serverSignature
equals the value sent by the server, we can trust it and continue communicating with it.
4. Rest
Now we have the authToken
we can use it in all our requests to the SkySpark REST API.
// Client Request: 2nd MessageGET /api/demo/about HTTP/1.1 Host: localhost:8080 Authorization: BEARER authToken=xxxyyyzzz
5. Fantom Code
A complete reference implementation of the SCRAM protocol for retreiving an authToken
from SkySpark, written in Fantom, is available in this BitBucket Snippet.
The sample code may be used like this:
// get the authTokenauthToken := SkySparkAuth().scram(`http://localhost:8080/ui`, "<username>", "<password>") echo("authToken: ${authToken}")// call the REST APIzincRes := WebClient(`http://localhost:8080/api/<proj>/about`) { it.reqHeaders["Authorization"] = "BEARER authToken=${authToken}" }.getStr
Note that the haystack-java library also contains a working SCRAM implementation.
Edits
- 26 December 2016 - Original article.
- 18 May 2017 - Made the link to the Fantom implemetation much clearer.
- 26 May 2018 - Added notes on
"Client Key"
and"Server Key"
being constant.