From 506c08778fe413c980e34aa95e8a38be36030c24 Mon Sep 17 00:00:00 2001 From: Dan Aprahamian Date: Mon, 7 May 2018 15:07:20 -0400 Subject: [PATCH] feat(auth): adds saslprep and SCRAM-SHA-256 - Adds SCRAM-SHA-256 and SASLPrep support to auth. - Throws an error if the iteration count in response to SASLStart is less than 4096. - Implements Mechanism Negotiations for default authentication method - Properly propagates server errors back to the user Fixes NODE-1311 --- index.js | 4 +- lib/auth/defaultAuthProviders.js | 29 ++++ lib/auth/scram.js | 162 +++++++++++++----- lib/connection/pool.js | 26 +-- lib/topologies/mongos.js | 16 +- lib/topologies/replset.js | 16 +- lib/topologies/server.js | 71 +++++++- package.json | 5 +- .../basic_single_server_auth_tests.js | 55 +++++- test/tests/unit/scram_iterations_tests.js | 62 +++++++ 10 files changed, 342 insertions(+), 104 deletions(-) create mode 100644 lib/auth/defaultAuthProviders.js create mode 100644 test/tests/unit/scram_iterations_tests.js diff --git a/index.js b/index.js index f3e3149a3..03d392493 100644 --- a/index.js +++ b/index.js @@ -28,11 +28,13 @@ module.exports = { // Raw operations Query: require('./lib/connection/commands').Query, // Auth mechanisms + defaultAuthProviders: require('./lib/auth/defaultAuthProviders').defaultAuthProviders, MongoCR: require('./lib/auth/mongocr'), X509: require('./lib/auth/x509'), Plain: require('./lib/auth/plain'), GSSAPI: require('./lib/auth/gssapi'), - ScramSHA1: require('./lib/auth/scram'), + ScramSHA1: require('./lib/auth/scram').ScramSHA1, + ScramSHA256: require('./lib/auth/scram').ScramSHA256, // Utilities parseConnectionString: require('./lib/uri_parser') }; diff --git a/lib/auth/defaultAuthProviders.js b/lib/auth/defaultAuthProviders.js new file mode 100644 index 000000000..fc5f4c283 --- /dev/null +++ b/lib/auth/defaultAuthProviders.js @@ -0,0 +1,29 @@ +'use strict'; + +const MongoCR = require('./mongocr'); +const X509 = require('./x509'); +const Plain = require('./plain'); +const GSSAPI = require('./gssapi'); +const SSPI = require('./sspi'); +const ScramSHA1 = require('./scram').ScramSHA1; +const ScramSHA256 = require('./scram').ScramSHA256; + +/** + * Returns the default authentication providers. + * + * @param {BSON} bson Bson definition + * @returns {Object} a mapping of auth names to auth types + */ +function defaultAuthProviders(bson) { + return { + mongocr: new MongoCR(bson), + x509: new X509(bson), + plain: new Plain(bson), + gssapi: new GSSAPI(bson), + sspi: new SSPI(bson), + 'scram-sha-1': new ScramSHA1(bson), + 'scram-sha-256': new ScramSHA256(bson) + }; +} + +module.exports = { defaultAuthProviders }; diff --git a/lib/auth/scram.js b/lib/auth/scram.js index feed83be0..754a0ef90 100644 --- a/lib/auth/scram.js +++ b/lib/auth/scram.js @@ -6,6 +6,14 @@ var f = require('util').format, Query = require('../connection/commands').Query, MongoError = require('../error').MongoError; +let saslprep; + +try { + saslprep = require('saslprep'); +} catch (e) { + // don't do anything; +} + var BSON = retrieveBSON(), Binary = BSON.Binary; @@ -26,14 +34,15 @@ AuthSession.prototype.equal = function(session) { var id = 0; /** - * Creates a new ScramSHA1 authentication mechanism + * Creates a new ScramSHA authentication mechanism * @class - * @return {ScramSHA1} A cursor instance + * @return {ScramSHA} A cursor instance */ -var ScramSHA1 = function(bson) { +var ScramSHA = function(bson, cryptoMethod) { this.bson = bson; this.authStore = []; this.id = id++; + this.cryptoMethod = cryptoMethod || 'sha1'; }; var parsePayload = function(payload) { @@ -60,21 +69,32 @@ var passwordDigest = function(username, password) { }; // XOR two buffers -var xor = function(a, b) { +function xor(a, b) { if (!Buffer.isBuffer(a)) a = new Buffer(a); if (!Buffer.isBuffer(b)) b = new Buffer(b); - var res = []; - if (a.length > b.length) { - for (var i = 0; i < b.length; i++) { - res.push(a[i] ^ b[i]); - } - } else { - for (i = 0; i < a.length; i++) { - res.push(a[i] ^ b[i]); - } + const length = Math.max(a.length, b.length); + const res = []; + + for (let i = 0; i < length; i += 1) { + res.push(a[i] ^ b[i]); } - return new Buffer(res); -}; + + return new Buffer(res).toString('base64'); +} + +function H(method, text) { + return crypto + .createHash(method) + .update(text) + .digest(); +} + +function HMAC(method, key, text) { + return crypto + .createHmac(method, key) + .update(text) + .digest(); +} var _hiCache = {}; var _hiCacheCount = 0; @@ -83,15 +103,26 @@ var _hiCachePurge = function() { _hiCacheCount = 0; }; -var hi = function(data, salt, iterations) { +const hiLengthMap = { + sha256: 32, + sha1: 20 +}; + +function HI(data, salt, iterations, cryptoMethod) { // omit the work if already generated - var key = [data, salt.toString('base64'), iterations].join('_'); + const key = [data, salt.toString('base64'), iterations].join('_'); if (_hiCache[key] !== undefined) { return _hiCache[key]; } // generate the salt - var saltedData = crypto.pbkdf2Sync(data, salt, iterations, 20, 'sha1'); + const saltedData = crypto.pbkdf2Sync( + data, + salt, + iterations, + hiLengthMap[cryptoMethod], + cryptoMethod + ); // cache a copy to speed up the next lookup, but prevent unbounded cache growth if (_hiCacheCount >= 200) { @@ -101,7 +132,7 @@ var hi = function(data, salt, iterations) { _hiCache[key] = saltedData; _hiCacheCount += 1; return saltedData; -}; +} /** * Authenticate @@ -114,7 +145,7 @@ var hi = function(data, salt, iterations) { * @param {authResultCallback} callback The callback to return the result from the authentication * @return {object} */ -ScramSHA1.prototype.auth = function(server, connections, db, username, password, callback) { +ScramSHA.prototype.auth = function(server, connections, db, username, password, callback) { var self = this; // Total connections var count = connections.length; @@ -124,6 +155,25 @@ ScramSHA1.prototype.auth = function(server, connections, db, username, password, var numberOfValidConnections = 0; var errorObject = null; + const cryptoMethod = this.cryptoMethod; + let mechanism = 'SCRAM-SHA-1'; + let processedPassword; + + if (cryptoMethod === 'sha256') { + mechanism = 'SCRAM-SHA-256'; + + let saslprepFn = (server.s && server.s.saslprep) || saslprep; + + if (saslprepFn) { + processedPassword = saslprepFn(password); + } else { + console.warn('Warning: no saslprep library specified. Passwords will not be sanitized'); + processedPassword = password; + } + } else { + processedPassword = passwordDigest(username, password); + } + // Execute MongoCR var executeScram = function(connection) { // Clean up the user @@ -132,13 +182,21 @@ ScramSHA1.prototype.auth = function(server, connections, db, username, password, // Create a random nonce var nonce = crypto.randomBytes(24).toString('base64'); // var nonce = 'MsQUY9iw0T9fx2MUEz6LZPwGuhVvWAhc' - var firstBare = f('n=%s,r=%s', username, nonce); + + // NOTE: This is done b/c Javascript uses UTF-16, but the server is hashing in UTF-8. + // Since the username is not sasl-prep-d, we need to do this here. + const firstBare = Buffer.concat([ + Buffer.from('n=', 'utf8'), + Buffer.from(username, 'utf8'), + Buffer.from(',r=', 'utf8'), + Buffer.from(nonce, 'utf8') + ]); // Build command structure var cmd = { saslStart: 1, - mechanism: 'SCRAM-SHA-1', - payload: new Binary(f('n,,%s', firstBare)), + mechanism: mechanism, + payload: new Binary(Buffer.concat([Buffer.from('n,,', 'utf8'), firstBare])), autoAuthorize: 1 }; @@ -220,38 +278,42 @@ ScramSHA1.prototype.auth = function(server, connections, db, username, password, // Set up start of proof var withoutProof = f('c=biws,r=%s', rnonce); - var passwordDig = passwordDigest(username, password); - var saltedPassword = hi(passwordDig, new Buffer(salt, 'base64'), iterations); + var saltedPassword = HI( + processedPassword, + new Buffer(salt, 'base64'), + iterations, + cryptoMethod + ); + + if (iterations && iterations < 4096) { + const error = new MongoError(`Server returned an invalid iteration count ${iterations}`); + return callback(error, false); + } // Create the client key - var hmac = crypto.createHmac('sha1', saltedPassword); - hmac.update(new Buffer('Client Key')); - var clientKey = new Buffer(hmac.digest('base64'), 'base64'); + const clientKey = HMAC(cryptoMethod, saltedPassword, 'Client Key'); // Create the stored key - var hash = crypto.createHash('sha1'); - hash.update(clientKey); - var storedKey = new Buffer(hash.digest('base64'), 'base64'); + const storedKey = H(cryptoMethod, clientKey); // Create the authentication message - var authMsg = [firstBare, r.result.payload.value().toString('base64'), withoutProof].join( - ',' - ); + const authMessage = [ + firstBare, + r.result.payload.value().toString('base64'), + withoutProof + ].join(','); // Create client signature - hmac = crypto.createHmac('sha1', storedKey); - hmac.update(new Buffer(authMsg)); - var clientSig = new Buffer(hmac.digest('base64'), 'base64'); + const clientSignature = HMAC(cryptoMethod, storedKey, authMessage); // Create client proof - var clientProof = f('p=%s', new Buffer(xor(clientKey, clientSig)).toString('base64')); + const clientProof = f('p=%s', xor(clientKey, clientSignature)); // Create client final - var clientFinal = [withoutProof, clientProof].join(','); + const clientFinal = [withoutProof, clientProof].join(','); - // // Create continue message - var cmd = { + const cmd = { saslContinue: 1, conversationId: r.result.conversationId, payload: new Binary(new Buffer(clientFinal)) @@ -326,7 +388,7 @@ var addAuthSession = function(authStore, session) { * @param {string} db Name of database we are removing authStore details about * @return {object} */ -ScramSHA1.prototype.logout = function(dbName) { +ScramSHA.prototype.logout = function(dbName) { this.authStore = this.authStore.filter(function(x) { return x.db !== dbName; }); @@ -340,7 +402,7 @@ ScramSHA1.prototype.logout = function(dbName) { * @param {authResultCallback} callback The callback to return the result from the authentication * @return {object} */ -ScramSHA1.prototype.reauthenticate = function(server, connections, callback) { +ScramSHA.prototype.reauthenticate = function(server, connections, callback) { var authStore = this.authStore.slice(0); var count = authStore.length; // No connections @@ -364,4 +426,16 @@ ScramSHA1.prototype.reauthenticate = function(server, connections, callback) { } }; -module.exports = ScramSHA1; +class ScramSHA1 extends ScramSHA { + constructor(bson) { + super(bson, 'sha1'); + } +} + +class ScramSHA256 extends ScramSHA { + constructor(bson) { + super(bson, 'sha256'); + } +} + +module.exports = { ScramSHA1, ScramSHA256 }; diff --git a/lib/connection/pool.js b/lib/connection/pool.js index 9923a9aa8..7d31ec272 100644 --- a/lib/connection/pool.js +++ b/lib/connection/pool.js @@ -18,12 +18,7 @@ var inherits = require('util').inherits, const apm = require('./apm'); -var MongoCR = require('../auth/mongocr'), - X509 = require('../auth/x509'), - Plain = require('../auth/plain'), - GSSAPI = require('../auth/gssapi'), - SSPI = require('../auth/sspi'), - ScramSHA1 = require('../auth/scram'); +const defaultAuthProviders = require('../auth/defaultAuthProviders').defaultAuthProviders; var DISCONNECTED = 'disconnected'; var CONNECTING = 'connecting'; @@ -147,14 +142,7 @@ var Pool = function(topology, options) { this.queue = []; // All the authProviders - this.authProviders = options.authProviders || { - mongocr: new MongoCR(options.bson), - x509: new X509(options.bson), - plain: new Plain(options.bson), - gssapi: new GSSAPI(options.bson), - sspi: new SSPI(options.bson), - 'scram-sha-1': new ScramSHA1(options.bson) - }; + this.authProviders = options.authProviders || defaultAuthProviders(options.bson); // Contains the reconnect connection this.reconnectConnection = null; @@ -824,7 +812,7 @@ Pool.prototype.auth = function(mechanism) { // Authenticate the connections for (var i = 0; i < connections.length; i++) { - authenticate(self, args, connections[i], function(err) { + authenticate(self, args, connections[i], function(err, result) { connectionsCount = connectionsCount - 1; // Store the error @@ -850,9 +838,9 @@ Pool.prototype.auth = function(mechanism) { ); } - return cb(error); + return cb(error, result); } - cb(null); + cb(null, result); } }); } @@ -869,12 +857,12 @@ Pool.prototype.auth = function(mechanism) { // Wait for loggout to finish waitForLogout(self, function() { // Authenticate all live connections - authenticateLiveConnections(self, args, function(err) { + authenticateLiveConnections(self, args, function(err, result) { // Credentials correctly stored in auth provider if successful // Any new connections will now reauthenticate correctly self.authenticating = false; // Return after authentication connections - callback(err); + callback(err, result); }); }); }; diff --git a/lib/topologies/mongos.js b/lib/topologies/mongos.js index 720ac81b9..725e5f6ad 100644 --- a/lib/topologies/mongos.js +++ b/lib/topologies/mongos.js @@ -37,12 +37,7 @@ const BSON = retrieveBSON(); * server.connect(); */ -var MongoCR = require('../auth/mongocr'), - X509 = require('../auth/x509'), - Plain = require('../auth/plain'), - GSSAPI = require('../auth/gssapi'), - SSPI = require('../auth/sspi'), - ScramSHA1 = require('../auth/scram'); +const defaultAuthProviders = require('../auth/defaultAuthProviders').defaultAuthProviders; // // States @@ -200,14 +195,7 @@ var Mongos = function(seedlist, options) { } // All the authProviders - this.authProviders = options.authProviders || { - mongocr: new MongoCR(this.s.bson), - x509: new X509(this.s.bson), - plain: new Plain(this.s.bson), - gssapi: new GSSAPI(this.s.bson), - sspi: new SSPI(this.s.bson), - 'scram-sha-1': new ScramSHA1(this.s.bson) - }; + this.authProviders = options.authProviders || defaultAuthProviders(this.s.bson); // Disconnected state this.state = DISCONNECTED; diff --git a/lib/topologies/replset.js b/lib/topologies/replset.js index 0189bfe05..5bee16b7a 100644 --- a/lib/topologies/replset.js +++ b/lib/topologies/replset.js @@ -19,12 +19,7 @@ const SessionMixins = require('./shared').SessionMixins; const isRetryableWritesSupported = require('./shared').isRetryableWritesSupported; const relayEvents = require('./shared').relayEvents; -var MongoCR = require('../auth/mongocr'), - X509 = require('../auth/x509'), - Plain = require('../auth/plain'), - GSSAPI = require('../auth/gssapi'), - SSPI = require('../auth/sspi'), - ScramSHA1 = require('../auth/scram'); +const defaultAuthProviders = require('../auth/defaultAuthProviders').defaultAuthProviders; var BSON = retrieveBSON(); @@ -223,14 +218,7 @@ var ReplSet = function(seedlist, options) { } // All the authProviders - this.authProviders = options.authProviders || { - mongocr: new MongoCR(this.s.bson), - x509: new X509(this.s.bson), - plain: new Plain(this.s.bson), - gssapi: new GSSAPI(this.s.bson), - sspi: new SSPI(this.s.bson), - 'scram-sha-1': new ScramSHA1(this.s.bson) - }; + this.authProviders = options.authProviders || defaultAuthProviders(this.s.bson); // Add forwarding of events from state handler var types = ['joined', 'left']; diff --git a/lib/topologies/server.js b/lib/topologies/server.js index d1076c660..772d9a5e9 100644 --- a/lib/topologies/server.js +++ b/lib/topologies/server.js @@ -21,6 +21,57 @@ var inherits = require('util').inherits, SessionMixins = require('./shared').SessionMixins, relayEvents = require('./shared').relayEvents; +function getSaslSupportedMechs(options) { + if (!options) { + return {}; + } + + const authArray = options.auth || []; + const authMechanism = authArray[0] || options.authMechanism; + const authSource = authArray[1] || options.authSource || options.dbName || 'admin'; + const user = authArray[2] || options.user; + + if (typeof authMechanism === 'string' && authMechanism.toUpperCase() !== 'DEFAULT') { + return {}; + } + + if (!user) { + return {}; + } + + return { saslSupportedMechs: `${authSource}.${user}` }; +} + +function getDefaultAuthMechanism(ismaster) { + if (ismaster) { + // If ismaster contains saslSupportedMechs, use scram-sha-256 + // if it is available, else scram-sha-1 + if (Array.isArray(ismaster.saslSupportedMechs)) { + return ismaster.saslSupportedMechs.indexOf('SCRAM-SHA-256') >= 0 + ? 'scram-sha-256' + : 'scram-sha-1'; + } + + // Fallback to legacy selection method. If wire version >= 3, use scram-sha-1 + if (ismaster.maxWireVersion >= 3) { + return 'scram-sha-1'; + } + } + + // Default for wireprotocol < 3 + return 'mongocr'; +} + +function extractIsMasterError(err, result) { + if (err) { + return err; + } + + if (result && result.result && result.result.ok === 0) { + return new MongoError(result.result); + } +} + // Used for filtering out fields for loggin var debugFields = [ 'reconnect', @@ -344,7 +395,10 @@ var eventHandler = function(self, event) { var query = new Query( self.s.bson, 'admin.$cmd', - { ismaster: true, client: self.clientInfo, compression: compressors }, + Object.assign( + { ismaster: true, client: self.clientInfo, compression: compressors }, + getSaslSupportedMechs(self.s.options) + ), queryOptions ); // Get start time @@ -358,9 +412,12 @@ var eventHandler = function(self, event) { function(err, result) { // Set initial lastIsMasterMS self.lastIsMasterMS = new Date().getTime() - start; - if (err) { + + const serverError = extractIsMasterError(err, result); + + if (serverError) { self.destroy(); - return self.emit('error', err); + return self.emit('error', serverError); } if (!isSupportedServer(result.result)) { @@ -893,12 +950,8 @@ Server.prototype.logout = function(dbName, callback) { Server.prototype.auth = function(mechanism, db) { var self = this; - // If we have the default mechanism we pick mechanism based on the wire - // protocol max version. If it's >= 3 then scram-sha1 otherwise mongodb-cr - if (mechanism === 'default' && self.ismaster && self.ismaster.maxWireVersion >= 3) { - mechanism = 'scram-sha-1'; - } else if (mechanism === 'default') { - mechanism = 'mongocr'; + if (mechanism === 'default') { + mechanism = getDefaultAuthMechanism(self.ismaster); } // Slice all the arguments off diff --git a/package.json b/package.json index afde28278..6c6970f3c 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "bugs": { "url": "https://github.com/mongodb-js/mongodb-core/issues" }, - "homepage": "https://github.com/mongodb-js/mongodb-core" + "homepage": "https://github.com/mongodb-js/mongodb-core", + "optionalDependencies": { + "saslprep": "^1.0.0" + } } diff --git a/test/tests/functional/basic_single_server_auth_tests.js b/test/tests/functional/basic_single_server_auth_tests.js index 14d5b12a2..aa58cfe74 100644 --- a/test/tests/functional/basic_single_server_auth_tests.js +++ b/test/tests/functional/basic_single_server_auth_tests.js @@ -8,7 +8,57 @@ var expect = require('chai').expect, Bson = require('bson'); // Skipped due to use of topology manager -describe.skip('Basic single server auth tests', function() { +describe('Basic single server auth tests', function() { + it('should correctly authenticate server using scram-sha-256 using connect auth', { + metadata: { requires: { topology: 'auth', mongodb: '>=3.7.3' } }, + test: function(done) { + const config = this.configuration; + const method = 'scram-sha-256'; + const user = 'user'; + const password = 'pencil'; + const createUserCommand = { + createUser: user, + pwd: password, + roles: [{ role: 'root', db: 'admin' }], + digestPassword: true + }; + const dropUserCommand = { dropUser: user }; + const auth = [method, 'admin', user, password]; + + const createUser = cb => executeCommand(config, 'admin', createUserCommand, cb); + const dropUser = cb => executeCommand(config, 'admin', dropUserCommand, { auth }, cb); + + const cleanup = err => { + executeCommand(config, 'admin', dropUserCommand, {}, () => done(err)); + }; + + createUser((cmdErr, r) => { + expect(cmdErr).to.be.null; + expect(r).to.exist; + + const server = new Server({ + host: this.configuration.host, + port: this.configuration.port, + bson: new Bson() + }); + + server.on('connect', _server => { + dropUser((dropUserErr, dropUserRes) => { + expect(dropUserErr).to.be.null; + expect(dropUserRes).to.exist; + + _server.destroy({ force: true }); + done(); + }); + }); + + server.on('error', cleanup); + + server.connect({ auth }); + }); + } + }); + it('should fail to authenticate server using scram-sha-1 using connect auth', { metadata: { requires: { topology: 'auth' } }, @@ -329,7 +379,8 @@ describe.skip('Basic single server auth tests', function() { } }); - it('should correctly authenticate server using scram-sha-1 using connect auth then logout', { + // This test is broken, we should fix it at some point + it.skip('should correctly authenticate server using scram-sha-1 using connect auth then logout', { metadata: { requires: { topology: 'auth' } }, test: function(done) { diff --git a/test/tests/unit/scram_iterations_tests.js b/test/tests/unit/scram_iterations_tests.js new file mode 100644 index 000000000..333a148d7 --- /dev/null +++ b/test/tests/unit/scram_iterations_tests.js @@ -0,0 +1,62 @@ +'use strict'; + +const expect = require('chai').expect; +const mock = require('mongodb-mock-server'); +const Server = require('../../../lib/topologies/server'); + +describe('SCRAM Iterations Tests', function() { + const test = {}; + + beforeEach(() => { + return mock.createServer().then(mockServer => { + test.server = mockServer; + }); + }); + + afterEach(() => mock.cleanup()); + + it('should error if iteration count is less than 4096', function(_done) { + const scramResponse = + 'r=IE+xNFeOcslsupAA+zkDVzHd5HfwoRuP7Wi8S4py+erf8PcNm7XIdXQyT52Nj3+M,s=AzomrlMs99A7oFxDLpgFvVb+CSvdyXuNagoWVw==,i=4000'; + let done = e => { + done = () => {}; + return _done(e); + }; + + test.server.setMessageHandler(request => { + const doc = request.document; + if (doc.ismaster) { + return request.reply(Object.assign({}, mock.DEFAULT_ISMASTER)); + } else if (doc.saslStart) { + return request.reply({ + ok: 1, + done: false, + payload: new Buffer(scramResponse) + }); + } else if (doc.saslContinue) { + done('SHOULD NOT BE HERE'); + } + }); + + const client = new Server(test.server.address()); + client.on('error', done); + client.once('connect', server => { + server.auth('default', 'db', 'user', 'pencil', (err, result) => { + let testErr; + try { + expect(err).to.not.be.null; + expect(err) + .to.have.property('message') + .that.matches(/Server returned an invalid iteration count/); + expect(result).to.be.false; + } catch (e) { + testErr = e; + } + client.destroy(); + done(testErr); + }); + }); + + client.connect(); + }); +});