Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SASL/SCRAM authentication #1835

Merged
merged 1 commit into from Mar 7, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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