diff --git a/lib/client.js b/lib/client.js index 8e7d307f7..d98f78cfb 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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 diff --git a/lib/connection.js b/lib/connection.js index 513e58981..1f0af2f11 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -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)) } diff --git a/lib/sasl.js b/lib/sasl.js new file mode 100644 index 000000000..537ca350b --- /dev/null +++ b/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 +} diff --git a/test/buffer-list.js b/test/buffer-list.js index 527743fb2..e0a9007bf 100644 --- a/test/buffer-list.js +++ b/test/buffer-list.js @@ -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) } diff --git a/test/integration/client/sasl-scram-tests.js b/test/integration/client/sasl-scram-tests.js new file mode 100644 index 000000000..f5326d8ae --- /dev/null +++ b/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() + }) + ) +}) +*/ diff --git a/test/test-buffers.js b/test/test-buffers.js index 9873a3100..60a549492 100644 --- a/test/test-buffers.js +++ b/test/test-buffers.js @@ -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) diff --git a/test/unit/client/sasl-scram-tests.js b/test/unit/client/sasl-scram-tests.js new file mode 100644 index 000000000..9987c6cfa --- /dev/null +++ b/test/unit/client/sasl-scram-tests.js @@ -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") + }) + + }) + +}) + diff --git a/test/unit/connection/inbound-parser-tests.js b/test/unit/connection/inbound-parser-tests.js index 0eaefcc45..7bb9a4329 100644 --- a/test/unit/connection/inbound-parser-tests.js +++ b/test/unit/connection/inbound-parser-tests.js @@ -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) diff --git a/test/unit/connection/outbound-sending-tests.js b/test/unit/connection/outbound-sending-tests.js index b8b72a214..6c36401f0 100644 --- a/test/unit/connection/outbound-sending-tests.js +++ b/test/unit/connection/outbound-sending-tests.js @@ -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)