sasl/scram authentication (#1835)

This commit is contained in:
andreme 2019-03-08 01:02:21 +08:00 committed by Brian C
parent 41706e64d4
commit 5a92ba3701
9 changed files with 473 additions and 18 deletions

View File

@ -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')
@ -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

View File

@ -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
@ -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))
}

146
lib/sasl.js Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
})
)
})
*/

View File

@ -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)

View File

@ -0,0 +1,135 @@
'use strict'
require('./test-helper');
var sasl = require('../../../lib/sasl')
test('sasl/scram', function () {
test('startSession', function () {
test('fails when mechanisms does not include SCRAM-SHA-256', function () {
assert.throws(function () {
sasl.startSession([])
}, {
message: 'SASL: Only mechanism SCRAM-SHA-256 is currently supported',
})
})
test('returns expected session data', function () {
const session = sasl.startSession(['SCRAM-SHA-256'])
assert.equal(session.mechanism, 'SCRAM-SHA-256')
assert.equal(String(session.clientNonce).length, 24)
assert.equal(session.message, 'SASLInitialResponse')
assert(session.response.match(/^n,,n=\*,r=.{24}/))
})
test('creates random nonces', function () {
const session1 = sasl.startSession(['SCRAM-SHA-256'])
const session2 = sasl.startSession(['SCRAM-SHA-256'])
assert(session1.clientNonce != session2.clientNonce)
})
})
test('continueSession', function () {
test('fails when last session message was not SASLInitialResponse', function () {
assert.throws(function () {
sasl.continueSession({})
}, {
message: 'SASL: Last message was not SASLInitialResponse',
})
})
test('fails when nonce is missing in server message', function () {
assert.throws(function () {
sasl.continueSession({
message: 'SASLInitialResponse',
}, "s=1,i=1")
}, {
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing',
})
})
test('fails when salt is missing in server message', function () {
assert.throws(function () {
sasl.continueSession({
message: 'SASLInitialResponse',
}, "r=1,i=1")
}, {
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing',
})
})
test('fails when iteration is missing in server message', function () {
assert.throws(function () {
sasl.continueSession({
message: 'SASLInitialResponse',
}, "r=1,s=1")
}, {
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing',
})
})
test('fails when server nonce does not start with client nonce', function () {
assert.throws(function () {
sasl.continueSession({
message: 'SASLInitialResponse',
clientNonce: '2',
}, 'r=1,s=1,i=1')
}, {
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce',
})
})
test('sets expected session data', function () {
const session = {
message: 'SASLInitialResponse',
clientNonce: 'a',
};
sasl.continueSession(session, 'password', 'r=ab,s=x,i=1')
assert.equal(session.message, 'SASLResponse')
assert.equal(session.serverSignature, 'TtywIrpWDJ0tCSXM2mjkyiaa8iGZsZG7HllQxr8fYAo=')
assert.equal(session.response, 'c=biws,r=ab,p=KAEPBUTjjofB0IM5UWcZApK1dSzFE0o5vnbWjBbvFHA=')
})
})
test('continueSession', function () {
test('fails when last session message was not SASLResponse', function () {
assert.throws(function () {
sasl.finalizeSession({})
}, {
message: 'SASL: Last message was not SASLResponse',
})
})
test('fails when server signature does not match', function () {
assert.throws(function () {
sasl.finalizeSession({
message: 'SASLResponse',
serverSignature: '3',
}, "v=4")
}, {
message: 'SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match',
})
})
test('does not fail when eveything is ok', function () {
sasl.finalizeSession({
message: 'SASLResponse',
serverSignature: '5',
}, "v=5")
})
})
})

View File

@ -135,6 +135,9 @@ var testForMessage = function (buffer, expectedMessage) {
var plainPasswordBuffer = buffers.authenticationCleartextPassword()
var md5PasswordBuffer = buffers.authenticationMD5Password()
var SASLBuffer = buffers.authenticationSASL()
var SASLContinueBuffer = buffers.authenticationSASLContinue()
var SASLFinalBuffer = buffers.authenticationSASLFinal()
var expectedPlainPasswordMessage = {
name: 'authenticationCleartextPassword'
@ -144,6 +147,20 @@ var expectedMD5PasswordMessage = {
name: 'authenticationMD5Password'
}
var expectedSASLMessage = {
name: 'authenticationSASL',
}
var expectedSASLContinueMessage = {
name: 'authenticationSASLContinue',
data: 'data',
}
var expectedSASLFinalMessage = {
name: 'authenticationSASLFinal',
data: 'data',
}
var notificationResponseBuffer = buffers.notification(4, 'hi', 'boom')
var expectedNotificationResponseMessage = {
name: 'notification',
@ -155,10 +172,18 @@ var expectedNotificationResponseMessage = {
test('Connection', function () {
testForMessage(authOkBuffer, expectedAuthenticationOkayMessage)
testForMessage(plainPasswordBuffer, expectedPlainPasswordMessage)
var msg = testForMessage(md5PasswordBuffer, expectedMD5PasswordMessage)
var msgMD5 = testForMessage(md5PasswordBuffer, expectedMD5PasswordMessage)
test('md5 has right salt', function () {
assert.equalBuffers(msg.salt, Buffer.from([1, 2, 3, 4]))
assert.equalBuffers(msgMD5.salt, Buffer.from([1, 2, 3, 4]))
})
var msgSASL = testForMessage(SASLBuffer, expectedSASLMessage)
test('SASL has the right mechanisms', function () {
assert.deepStrictEqual(msgSASL.mechanisms, ['SCRAM-SHA-256'])
})
testForMessage(SASLContinueBuffer, expectedSASLContinueMessage)
testForMessage(SASLFinalBuffer, expectedSASLFinalMessage)
testForMessage(paramStatusBuffer, expectedParameterStatusMessage)
testForMessage(backendKeyDataBuffer, expectedBackendKeyDataMessage)
testForMessage(readyForQueryBuffer, expectedReadyForQueryMessage)

View File

@ -34,6 +34,16 @@ test('sends password message', function () {
assert.received(stream, new BufferList().addCString('!').join(true, 'p'))
})
test('sends SASLInitialResponseMessage message', function () {
con.sendSASLInitialResponseMessage('mech', 'data')
assert.received(stream, new BufferList().addCString('mech').addInt32(4).addString('data').join(true, 'p'))
})
test('sends SCRAMClientFinalMessage message', function () {
con.sendSCRAMClientFinalMessage('data')
assert.received(stream, new BufferList().addString('data').join(true, 'p'))
})
test('sends query message', function () {
var txt = 'select * from boom'
con.query(txt)