From 5532ca51db96f3370faf66d9e13f0ba226844f62 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 19 Apr 2023 14:38:06 +0100 Subject: [PATCH] Use WebCrypto APIs where possible The only place we are stuck with node's original crypto API is for generating md5 hashes, which are not supported by WebCrypto. --- packages/pg/lib/client.js | 17 ++-- packages/pg/lib/{ => crypto}/sasl.js | 37 +++----- packages/pg/lib/crypto/utils-legacy.js | 37 ++++++++ packages/pg/lib/crypto/utils.js | 92 +++++++++++++++++++ packages/pg/lib/utils.js | 19 +--- .../integration/connection/test-helper.js | 5 +- .../pg/test/unit/client/md5-password-tests.js | 26 +++--- .../pg/test/unit/client/sasl-scram-tests.js | 76 +++++++-------- 8 files changed, 208 insertions(+), 101 deletions(-) rename packages/pg/lib/{ => crypto}/sasl.js (86%) create mode 100644 packages/pg/lib/crypto/utils-legacy.js create mode 100644 packages/pg/lib/crypto/utils.js diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index b7143e9d0..88f2f5f36 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -2,13 +2,14 @@ var EventEmitter = require('events').EventEmitter var utils = require('./utils') -var sasl = require('./sasl') +var sasl = require('./crypto/sasl') var TypeOverrides = require('./type-overrides') var ConnectionParameters = require('./connection-parameters') var Query = require('./query') var defaults = require('./defaults') var Connection = require('./connection') +const crypto = require('./crypto/utils') class Client extends EventEmitter { constructor(config) { @@ -245,9 +246,13 @@ class Client extends EventEmitter { } _handleAuthMD5Password(msg) { - this._checkPgPass(() => { - const hashedPassword = utils.postgresMd5PasswordHash(this.user, this.password, msg.salt) - this.connection.password(hashedPassword) + this._checkPgPass(async () => { + try { + const hashedPassword = await crypto.postgresMd5PasswordHash(this.user, this.password, msg.salt) + this.connection.password(hashedPassword) + } catch (e) { + this.emit('error', e) + } }) } @@ -262,9 +267,9 @@ class Client extends EventEmitter { }) } - _handleAuthSASLContinue(msg) { + async _handleAuthSASLContinue(msg) { try { - sasl.continueSession(this.saslSession, this.password, msg.data) + await sasl.continueSession(this.saslSession, this.password, msg.data) this.connection.sendSCRAMClientFinalMessage(this.saslSession.response) } catch (err) { this.connection.emit('error', err) diff --git a/packages/pg/lib/sasl.js b/packages/pg/lib/crypto/sasl.js similarity index 86% rename from packages/pg/lib/sasl.js rename to packages/pg/lib/crypto/sasl.js index c8d2d2bdc..04ae19724 100644 --- a/packages/pg/lib/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,5 +1,5 @@ 'use strict' -const crypto = require('crypto') +const crypto = require('./utils') function startSession(mechanisms) { if (mechanisms.indexOf('SCRAM-SHA-256') === -1) { @@ -16,7 +16,7 @@ function startSession(mechanisms) { } } -function continueSession(session, password, serverData) { +async function continueSession(session, password, serverData) { if (session.message !== 'SASLInitialResponse') { throw new Error('SASL: Last message was not SASLInitialResponse') } @@ -38,29 +38,22 @@ function continueSession(session, password, serverData) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short') } - var saltBytes = Buffer.from(sv.salt, 'base64') - - var saltedPassword = crypto.pbkdf2Sync(password, saltBytes, sv.iteration, 32, 'sha256') - - var clientKey = hmacSha256(saltedPassword, 'Client Key') - var storedKey = sha256(clientKey) - 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 = hmacSha256(storedKey, authMessage) - var clientProofBytes = xorBuffers(clientKey, clientSignature) - var clientProof = clientProofBytes.toString('base64') - - var serverKey = hmacSha256(saltedPassword, 'Server Key') - var serverSignatureBytes = hmacSha256(serverKey, authMessage) + var saltBytes = Buffer.from(sv.salt, 'base64') + var saltedPassword = await crypto.deriveKey(password, saltBytes, sv.iteration) + var clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key') + var storedKey = await crypto.sha256(clientKey) + var clientSignature = await crypto.hmacSha256(storedKey, authMessage) + var clientProof = xorBuffers(Buffer.from(clientKey), Buffer.from(clientSignature)).toString('base64') + var serverKey = await crypto.hmacSha256(saltedPassword, 'Server Key') + var serverSignatureBytes = await crypto.hmacSha256(serverKey, authMessage) session.message = 'SASLResponse' - session.serverSignature = serverSignatureBytes.toString('base64') + session.serverSignature = Buffer.from(serverSignatureBytes).toString('base64') session.response = clientFinalMessageWithoutProof + ',p=' + clientProof } @@ -186,14 +179,6 @@ function xorBuffers(a, b) { return Buffer.from(a.map((_, i) => a[i] ^ b[i])) } -function sha256(text) { - return crypto.createHash('sha256').update(text).digest() -} - -function hmacSha256(key, msg) { - return crypto.createHmac('sha256', key).update(msg).digest() -} - module.exports = { startSession, continueSession, diff --git a/packages/pg/lib/crypto/utils-legacy.js b/packages/pg/lib/crypto/utils-legacy.js new file mode 100644 index 000000000..86544ad00 --- /dev/null +++ b/packages/pg/lib/crypto/utils-legacy.js @@ -0,0 +1,37 @@ +'use strict' +// This file contains crypto utility functions for versions of Node.js < 15.0.0, +// which does not support the WebCrypto.subtle API. + +const nodeCrypto = require('crypto') + +function md5(string) { + return nodeCrypto.createHash('md5').update(string, 'utf-8').digest('hex') +} + +// See AuthenticationMD5Password at https://www.postgresql.org/docs/current/static/protocol-flow.html +function postgresMd5PasswordHash(user, password, salt) { + var inner = md5(password + user) + var outer = md5(Buffer.concat([Buffer.from(inner), salt])) + return 'md5' + outer +} + +function sha256(text) { + return nodeCrypto.createHash('sha256').update(text).digest() +} + +function hmacSha256(key, msg) { + return nodeCrypto.createHmac('sha256', key).update(msg).digest() +} + +async function deriveKey(password, salt, iterations) { + return nodeCrypto.pbkdf2Sync(password, salt, iterations, 32, 'sha256') +} + +module.exports = { + postgresMd5PasswordHash, + randomBytes: nodeCrypto.randomBytes, + deriveKey, + sha256, + hmacSha256, + md5, +} diff --git a/packages/pg/lib/crypto/utils.js b/packages/pg/lib/crypto/utils.js new file mode 100644 index 000000000..ca2b821c6 --- /dev/null +++ b/packages/pg/lib/crypto/utils.js @@ -0,0 +1,92 @@ +'use strict' + +const useLegacyCrypto = parseInt(process.versions && process.versions.node && process.versions.node.split('.')[0]) < 15 +if (useLegacyCrypto) { + // We are on an old version of Node.js that requires legacy crypto utilities. + module.exports = require('./utils-legacy') + return +} + +const nodeCrypto = require('crypto') + +module.exports = { + postgresMd5PasswordHash, + randomBytes, + deriveKey, + sha256, + hmacSha256, + md5, +} + +/** + * The Web Crypto API - grabbed from the Node.js library or the global + * @type Crypto + */ +const webCrypto = nodeCrypto.webcrypto || globalThis.crypto +/** + * The SubtleCrypto API for low level crypto operations. + * @type SubtleCrypto + */ +const subtleCrypto = webCrypto.subtle +const textEncoder = new TextEncoder() + +/** + * + * @param {*} length + * @returns + */ +function randomBytes(length) { + return webCrypto.getRandomValues(Buffer.alloc(length)) +} + +async function md5(string) { + try { + return nodeCrypto.createHash('md5').update(string, 'utf-8').digest('hex') + } catch (e) { + // `createHash()` failed so we are probably not in Node.js, use the WebCrypto API instead. + // Note that the MD5 algorithm on WebCrypto is not available in Node.js. + // This is why we cannot just use WebCrypto in all environments. + const data = typeof string === 'string' ? textEncoder.encode(string) : string + const hash = await subtleCrypto.digest('MD5', data) + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + } +} + +// See AuthenticationMD5Password at https://www.postgresql.org/docs/current/static/protocol-flow.html +async function postgresMd5PasswordHash(user, password, salt) { + var inner = await md5(password + user) + var outer = await md5(Buffer.concat([Buffer.from(inner), salt])) + return 'md5' + outer +} + +/** + * Create a SHA-256 digest of the given data + * @param {Buffer} data + */ +async function sha256(text) { + return await subtleCrypto.digest('SHA-256', text) +} + +/** + * Sign the message with the given key + * @param {ArrayBuffer} keyBuffer + * @param {string} msg + */ +async function hmacSha256(keyBuffer, msg) { + const key = await subtleCrypto.importKey('raw', keyBuffer, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + return await subtleCrypto.sign('HMAC', key, textEncoder.encode(msg)) +} + +/** + * Derive a key from the password and salt + * @param {string} password + * @param {Uint8Array} salt + * @param {number} iterations + */ +async function deriveKey(password, salt, iterations) { + const key = await subtleCrypto.importKey('raw', textEncoder.encode(password), 'PBKDF2', false, ['deriveBits']) + const params = { name: 'PBKDF2', hash: 'SHA-256', salt: salt, iterations: iterations } + return await subtleCrypto.deriveBits(params, key, 32 * 8, ['deriveBits']) +} diff --git a/packages/pg/lib/utils.js b/packages/pg/lib/utils.js index 1b8fdaf46..c82b6d893 100644 --- a/packages/pg/lib/utils.js +++ b/packages/pg/lib/utils.js @@ -1,7 +1,5 @@ 'use strict' -const crypto = require('crypto') - const defaults = require('./defaults') function escapeElement(elementRepresentation) { @@ -164,17 +162,6 @@ function normalizeQueryConfig(config, values, callback) { return config } -const md5 = function (string) { - return crypto.createHash('md5').update(string, 'utf-8').digest('hex') -} - -// See AuthenticationMD5Password at https://www.postgresql.org/docs/current/static/protocol-flow.html -const postgresMd5PasswordHash = function (user, password, salt) { - var inner = md5(password + user) - var outer = md5(Buffer.concat([Buffer.from(inner), salt])) - return 'md5' + outer -} - // Ported from PostgreSQL 9.2.4 source code in src/interfaces/libpq/fe-exec.c const escapeIdentifier = function (str) { return '"' + str.replace(/"/g, '""') + '"' @@ -205,8 +192,6 @@ const escapeLiteral = function (str) { return escaped } - - module.exports = { prepareValue: function prepareValueWrapper(value) { // this ensures that extra arguments do not get passed into prepareValue @@ -214,8 +199,6 @@ module.exports = { return prepareValue(value) }, normalizeQueryConfig, - postgresMd5PasswordHash, - md5, escapeIdentifier, - escapeLiteral + escapeLiteral, } diff --git a/packages/pg/test/integration/connection/test-helper.js b/packages/pg/test/integration/connection/test-helper.js index a94c64be5..475f30333 100644 --- a/packages/pg/test/integration/connection/test-helper.js +++ b/packages/pg/test/integration/connection/test-helper.js @@ -3,6 +3,7 @@ var net = require('net') var helper = require('../test-helper') var Connection = require('../../../lib/connection') var utils = require('../../../lib/utils') +const crypto = require('../../../lib/crypto/utils') var connect = function (callback) { var username = helper.args.user var database = helper.args.database @@ -20,8 +21,8 @@ var connect = function (callback) { con.once('authenticationCleartextPassword', function () { con.password(helper.args.password) }) - con.once('authenticationMD5Password', function (msg) { - con.password(utils.postgresMd5PasswordHash(helper.args.user, helper.args.password, msg.salt)) + con.once('authenticationMD5Password', async function (msg) { + con.password(await crypto.postgresMd5PasswordHash(helper.args.user, helper.args.password, msg.salt)) }) con.once('readyForQuery', function () { con.query('create temp table ids(id integer)') diff --git a/packages/pg/test/unit/client/md5-password-tests.js b/packages/pg/test/unit/client/md5-password-tests.js index 71f502087..8a425fa5e 100644 --- a/packages/pg/test/unit/client/md5-password-tests.js +++ b/packages/pg/test/unit/client/md5-password-tests.js @@ -1,24 +1,26 @@ 'use strict' var helper = require('./test-helper') const BufferList = require('../../buffer-list') -var utils = require('../../../lib/utils') +var crypto = require('../../../lib/crypto/utils') -test('md5 authentication', function () { +test('md5 authentication', async function () { var client = helper.createClient() client.password = '!' var salt = Buffer.from([1, 2, 3, 4]) - client.connection.emit('authenticationMD5Password', { salt: salt }) + await client.connection.emit('authenticationMD5Password', { salt: salt }) - test('responds', function () { - assert.lengthIs(client.connection.stream.packets, 1) - test('should have correct encrypted data', function () { - var password = utils.postgresMd5PasswordHash(client.user, client.password, salt) - // how do we want to test this? - assert.equalBuffers(client.connection.stream.packets[0], new BufferList().addCString(password).join(true, 'p')) + setTimeout(() => + test('responds', function () { + assert.lengthIs(client.connection.stream.packets, 1) + test('should have correct encrypted data', async function () { + var password = await crypto.postgresMd5PasswordHash(client.user, client.password, salt) + // how do we want to test this? + assert.equalBuffers(client.connection.stream.packets[0], new BufferList().addCString(password).join(true, 'p')) + }) }) - }) + ) }) -test('md5 of utf-8 strings', function () { - assert.equal(utils.md5('😊'), '5deda34cd95f304948d2bc1b4a62c11e') +test('md5 of utf-8 strings', async function () { + assert.equal(await crypto.md5('😊'), '5deda34cd95f304948d2bc1b4a62c11e') }) diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 36a5556b4..5ccf1709f 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -1,11 +1,13 @@ 'use strict' -require('./test-helper') +const helper = require('./test-helper') -var sasl = require('../../../lib/sasl') +var sasl = require('../../../lib/crypto/sasl') -test('sasl/scram', function () { - test('startSession', function () { - test('fails when mechanisms does not include SCRAM-SHA-256', function () { +const suite = new helper.Suite() + +suite.test('sasl/scram', function () { + suite.test('startSession', function () { + suite.test('fails when mechanisms does not include SCRAM-SHA-256', function () { assert.throws( function () { sasl.startSession([]) @@ -16,7 +18,7 @@ test('sasl/scram', function () { ) }) - test('returns expected session data', function () { + suite.test('returns expected session data', function () { const session = sasl.startSession(['SCRAM-SHA-256']) assert.equal(session.mechanism, 'SCRAM-SHA-256') @@ -26,7 +28,7 @@ test('sasl/scram', function () { assert(session.response.match(/^n,,n=\*,r=.{24}/)) }) - test('creates random nonces', function () { + suite.test('creates random nonces', function () { const session1 = sasl.startSession(['SCRAM-SHA-256']) const session2 = sasl.startSession(['SCRAM-SHA-256']) @@ -34,11 +36,11 @@ test('sasl/scram', function () { }) }) - test('continueSession', function () { - test('fails when last session message was not SASLInitialResponse', function () { - assert.throws( + suite.test('continueSession', function () { + suite.testAsync('fails when last session message was not SASLInitialResponse', async function () { + assert.rejects( function () { - sasl.continueSession({}, '', '') + return sasl.continueSession({}, '', '') }, { message: 'SASL: Last message was not SASLInitialResponse', @@ -46,10 +48,10 @@ test('sasl/scram', function () { ) }) - test('fails when nonce is missing in server message', function () { - assert.throws( + suite.testAsync('fails when nonce is missing in server message', function () { + assert.rejects( function () { - sasl.continueSession( + return sasl.continueSession( { message: 'SASLInitialResponse', }, @@ -63,10 +65,10 @@ test('sasl/scram', function () { ) }) - test('fails when salt is missing in server message', function () { - assert.throws( + suite.testAsync('fails when salt is missing in server message', function () { + assert.rejects( function () { - sasl.continueSession( + return sasl.continueSession( { message: 'SASLInitialResponse', }, @@ -80,11 +82,11 @@ test('sasl/scram', function () { ) }) - test('fails when client password is not a string', function () { - for(const badPasswordValue of [null, undefined, 123, new Date(), {}]) { - assert.throws( + suite.testAsync('fails when client password is not a string', function () { + for (const badPasswordValue of [null, undefined, 123, new Date(), {}]) { + assert.rejects( function () { - sasl.continueSession( + return sasl.continueSession( { message: 'SASLInitialResponse', clientNonce: 'a', @@ -100,10 +102,10 @@ test('sasl/scram', function () { } }) - test('fails when client password is an empty string', function () { - assert.throws( + suite.testAsync('fails when client password is an empty string', function () { + assert.rejects( function () { - sasl.continueSession( + return sasl.continueSession( { message: 'SASLInitialResponse', clientNonce: 'a', @@ -118,10 +120,10 @@ test('sasl/scram', function () { ) }) - test('fails when iteration is missing in server message', function () { - assert.throws( + suite.testAsync('fails when iteration is missing in server message', function () { + assert.rejects( function () { - sasl.continueSession( + return sasl.continueSession( { message: 'SASLInitialResponse', }, @@ -135,10 +137,10 @@ test('sasl/scram', function () { ) }) - test('fails when server nonce does not start with client nonce', function () { - assert.throws( + suite.testAsync('fails when server nonce does not start with client nonce', function () { + assert.rejects( function () { - sasl.continueSession( + return sasl.continueSession( { message: 'SASLInitialResponse', clientNonce: '2', @@ -153,13 +155,13 @@ test('sasl/scram', function () { ) }) - test('sets expected session data', function () { + suite.testAsync('sets expected session data', async function () { const session = { message: 'SASLInitialResponse', clientNonce: 'a', } - sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1') + await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1') assert.equal(session.message, 'SASLResponse') assert.equal(session.serverSignature, 'jwt97IHWFn7FEqHykPTxsoQrKGOMXJl/PJyJ1JXTBKc=') @@ -168,8 +170,8 @@ test('sasl/scram', function () { }) }) - test('continueSession', function () { - test('fails when last session message was not SASLResponse', function () { + suite.test('finalizeSession', function () { + suite.test('fails when last session message was not SASLResponse', function () { assert.throws( function () { sasl.finalizeSession({}) @@ -180,7 +182,7 @@ test('sasl/scram', function () { ) }) - test('fails when server signature is not valid base64', function () { + suite.test('fails when server signature is not valid base64', function () { assert.throws( function () { sasl.finalizeSession( @@ -197,7 +199,7 @@ test('sasl/scram', function () { ) }) - test('fails when server signature does not match', function () { + suite.test('fails when server signature does not match', function () { assert.throws( function () { sasl.finalizeSession( @@ -214,7 +216,7 @@ test('sasl/scram', function () { ) }) - test('does not fail when eveything is ok', function () { + suite.test('does not fail when eveything is ok', function () { sasl.finalizeSession( { message: 'SASLResponse',