mirror of
https://github.com/brianc/node-postgres.git
synced 2026-01-18 15:55:05 +00:00
sasl/scram authentication (#1835)
This commit is contained in:
parent
41706e64d4
commit
5a92ba3701
@ -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
|
||||
|
||||
@ -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
146
lib/sasl.js
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
41
test/integration/client/sasl-scram-tests.js
Normal file
41
test/integration/client/sasl-scram-tests.js
Normal 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()
|
||||
})
|
||||
)
|
||||
})
|
||||
*/
|
||||
@ -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)
|
||||
|
||||
135
test/unit/client/sasl-scram-tests.js
Normal file
135
test/unit/client/sasl-scram-tests.js
Normal 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")
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user