Skip to content

Commit

Permalink
sasl/scram authentication (#1835)
Browse files Browse the repository at this point in the history
  • Loading branch information
andreme authored and brianc committed Mar 7, 2019
1 parent 41706e6 commit 5a92ba3
Show file tree
Hide file tree
Showing 9 changed files with 473 additions and 18 deletions.
23 changes: 23 additions & 0 deletions lib/client.js
Expand Up @@ -10,6 +10,7 @@
var EventEmitter = require('events').EventEmitter
var util = require('util')
var utils = require('./utils')
var sasl = require('./sasl')
var pgPass = require('pgpass')
var TypeOverrides = require('./type-overrides')

Expand Down Expand Up @@ -126,6 +127,28 @@ Client.prototype._connect = function (callback) {
con.password(utils.postgresMd5PasswordHash(self.user, self.password, msg.salt))
}))

// password request handling (SASL)
var saslSession
con.on('authenticationSASL', checkPgPass(function (msg) {
saslSession = sasl.startSession(msg.mechanisms)

con.sendSASLInitialResponseMessage(saslSession.mechanism, saslSession.response)
}))

// password request handling (SASL)
con.on('authenticationSASLContinue', function (msg) {
sasl.continueSession(saslSession, self.password, msg.data)

con.sendSCRAMClientFinalMessage(saslSession.response)
})

// password request handling (SASL)
con.on('authenticationSASLFinal', function (msg) {
sasl.finalizeSession(saslSession, msg.data)

saslSession = null
})

con.once('backendKeyData', function (msg) {
self.processID = msg.processID
self.secretKey = msg.secretKey
Expand Down
78 changes: 62 additions & 16 deletions lib/connection.js
Expand Up @@ -191,6 +191,24 @@ Connection.prototype.password = function (password) {
this._send(0x70, this.writer.addCString(password))
}

Connection.prototype.sendSASLInitialResponseMessage = function (mechanism, initialResponse) {
// 0x70 = 'p'
this.writer
.addCString(mechanism)
.addInt32(Buffer.byteLength(initialResponse))
.addString(initialResponse)

this._send(0x70)
}

Connection.prototype.sendSCRAMClientFinalMessage = function (additionalData) {
// 0x70 = 'p'
this.writer
.addString(additionalData)

this._send(0x70)
}

Connection.prototype._send = function (code, more) {
if (!this.stream.writable) {
return false
Expand Down Expand Up @@ -421,25 +439,53 @@ Connection.prototype.parseMessage = function (buffer) {
}

Connection.prototype.parseR = function (buffer, length) {
var code = 0
var code = this.parseInt32(buffer)

var msg = new Message('authenticationOk', length)
if (msg.length === 8) {
code = this.parseInt32(buffer)
if (code === 3) {
msg.name = 'authenticationCleartextPassword'
}
return msg
}
if (msg.length === 12) {
code = this.parseInt32(buffer)
if (code === 5) { // md5 required
msg.name = 'authenticationMD5Password'
msg.salt = Buffer.alloc(4)
buffer.copy(msg.salt, 0, this.offset, this.offset + 4)
this.offset += 4

switch (code) {
case 0: // AuthenticationOk
return msg
case 3: // AuthenticationCleartextPassword
if (msg.length === 8) {
msg.name = 'authenticationCleartextPassword'
return msg
}
break
case 5: // AuthenticationMD5Password
if (msg.length === 12) {
msg.name = 'authenticationMD5Password'
msg.salt = Buffer.alloc(4)
buffer.copy(msg.salt, 0, this.offset, this.offset + 4)
this.offset += 4
return msg
}

break
case 10: // AuthenticationSASL
msg.name = 'authenticationSASL'
msg.mechanisms = []
do {
var mechanism = this.parseCString(buffer)

if (mechanism) {
msg.mechanisms.push(mechanism)
}
} while (mechanism)

return msg
case 11: // AuthenticationSASLContinue
msg.name = 'authenticationSASLContinue'
msg.data = this.readString(buffer, length - 4)

return msg
case 12: // AuthenticationSASLFinal
msg.name = 'authenticationSASLFinal'
msg.data = this.readString(buffer, length - 4)

return msg
}
}

throw new Error('Unknown authenticationOk message type' + util.inspect(msg))
}

Expand Down
146 changes: 146 additions & 0 deletions lib/sasl.js
@@ -0,0 +1,146 @@
const crypto = require('crypto')

function startSession (mechanisms) {
if (mechanisms.indexOf('SCRAM-SHA-256') === -1) {
throw new Error('SASL: Only mechanism SCRAM-SHA-256 is currently supported')
}

const clientNonce = crypto.randomBytes(18).toString('base64')

return {
mechanism: 'SCRAM-SHA-256',
clientNonce,
response: 'n,,n=*,r=' + clientNonce,
message: 'SASLInitialResponse'
}
}

function continueSession (session, password, serverData) {
if (session.message !== 'SASLInitialResponse') {
throw new Error('SASL: Last message was not SASLInitialResponse')
}

const sv = extractVariablesFromFirstServerMessage(serverData)

if (!sv.nonce.startsWith(session.clientNonce)) {
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce')
}

var saltBytes = Buffer.from(sv.salt, 'base64')

var saltedPassword = Hi(password, saltBytes, sv.iteration)

var clientKey = createHMAC(saltedPassword, 'Client Key')
var storedKey = crypto.createHash('sha256').update(clientKey).digest()

var clientFirstMessageBare = 'n=*,r=' + session.clientNonce
var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration

var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce

var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof

var clientSignature = createHMAC(storedKey, authMessage)
var clientProofBytes = xorBuffers(clientKey, clientSignature)
var clientProof = clientProofBytes.toString('base64')

var serverKey = createHMAC(saltedPassword, 'Server Key')
var serverSignatureBytes = createHMAC(serverKey, authMessage)

session.message = 'SASLResponse'
session.serverSignature = serverSignatureBytes.toString('base64')
session.response = clientFinalMessageWithoutProof + ',p=' + clientProof
}

function finalizeSession (session, serverData) {
if (session.message !== 'SASLResponse') {
throw new Error('SASL: Last message was not SASLResponse')
}

var serverSignature

String(serverData).split(',').forEach(function (part) {
switch (part[0]) {
case 'v':
serverSignature = part.substr(2)
break
}
})

if (serverSignature !== session.serverSignature) {
throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match')
}
}

function extractVariablesFromFirstServerMessage (data) {
var nonce, salt, iteration

String(data).split(',').forEach(function (part) {
switch (part[0]) {
case 'r':
nonce = part.substr(2)
break
case 's':
salt = part.substr(2)
break
case 'i':
iteration = parseInt(part.substr(2), 10)
break
}
})

if (!nonce) {
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing')
}

if (!salt) {
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing')
}

if (!iteration) {
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing')
}

return {
nonce,
salt,
iteration
}
}

function xorBuffers (a, b) {
if (!Buffer.isBuffer(a)) a = Buffer.from(a)
if (!Buffer.isBuffer(b)) b = Buffer.from(b)
var res = []
if (a.length > b.length) {
for (var i = 0; i < b.length; i++) {
res.push(a[i] ^ b[i])
}
} else {
for (var j = 0; j < a.length; j++) {
res.push(a[j] ^ b[j])
}
}
return Buffer.from(res)
}

function createHMAC (key, msg) {
return crypto.createHmac('sha256', key).update(msg).digest()
}

function Hi (password, saltBytes, iterations) {
var ui1 = createHMAC(password, Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])]))
var ui = ui1
for (var i = 0; i < iterations - 1; i++) {
ui1 = createHMAC(password, ui1)
ui = xorBuffers(ui, ui1)
}

return ui
}

module.exports = {
startSession,
continueSession,
finalizeSession
}
7 changes: 7 additions & 0 deletions test/buffer-list.js
Expand Up @@ -36,6 +36,13 @@ p.addCString = function (val, front) {
return this.add(buffer, front)
}

p.addString = function (val, front) {
var len = Buffer.byteLength(val)
var buffer = Buffer.alloc(len)
buffer.write(val)
return this.add(buffer, front)
}

p.addChar = function (char, first) {
return this.add(Buffer.from(char, 'utf8'), first)
}
Expand Down
41 changes: 41 additions & 0 deletions test/integration/client/sasl-scram-tests.js
@@ -0,0 +1,41 @@
'use strict'
var helper = require(__dirname + '/../test-helper')
var pg = helper.pg

var suite = new helper.Suite()

/*
SQL to create test role:
set password_encryption = 'scram-sha-256';
create role npgtest login password 'test';
pg_hba:
host all npgtest ::1/128 scram-sha-256
host all npgtest 0.0.0.0/0 scram-sha-256
*/
/*
suite.test('can connect using sasl/scram', function () {
var connectionString = 'pg://npgtest:test@localhost/postgres'
const pool = new pg.Pool({ connectionString: connectionString })
pool.connect(
assert.calls(function (err, client, done) {
assert.ifError(err, 'should have connected')
done()
})
)
})
suite.test('sasl/scram fails when password is wrong', function () {
var connectionString = 'pg://npgtest:bad@localhost/postgres'
const pool = new pg.Pool({ connectionString: connectionString })
pool.connect(
assert.calls(function (err, client, done) {
assert.ok(err, 'should have a connection error')
done()
})
)
})
*/
22 changes: 22 additions & 0 deletions test/test-buffers.js
Expand Up @@ -28,6 +28,28 @@ buffers.authenticationMD5Password = function () {
.join(true, 'R')
}

buffers.authenticationSASL = function () {
return new BufferList()
.addInt32(10)
.addCString('SCRAM-SHA-256')
.addCString('')
.join(true, 'R')
}

buffers.authenticationSASLContinue = function () {
return new BufferList()
.addInt32(11)
.addString('data')
.join(true, 'R')
}

buffers.authenticationSASLFinal = function () {
return new BufferList()
.addInt32(12)
.addString('data')
.join(true, 'R')
}

buffers.parameterStatus = function (name, value) {
return new BufferList()
.addCString(name)
Expand Down

0 comments on commit 5a92ba3

Please sign in to comment.