From 65e1c7253f7fd23e09d9151481054e2efbaad413 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Fri, 10 Jan 2025 12:05:03 +0000 Subject: [PATCH 01/17] Added support for SCRAM-SHA-256-PLUS i.e. channel binding --- packages/pg/lib/client.js | 5 +- packages/pg/lib/crypto/sasl.js | 59 ++++++++++++++++--- packages/pg/lib/crypto/utils-legacy.js | 5 ++ packages/pg/lib/crypto/utils-webcrypto.js | 5 ++ packages/pg/package.json | 1 + .../integration/client/sasl-scram-tests.js | 15 ++++- 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 527f62e4f..9a817b179 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -20,6 +20,7 @@ class Client extends EventEmitter { this.database = this.connectionParameters.database this.port = this.connectionParameters.port this.host = this.connectionParameters.host + this.enableChannelBinding = true // "hiding" the password so it doesn't show up in stack traces // or if the client is console.logged @@ -258,7 +259,7 @@ class Client extends EventEmitter { _handleAuthSASL(msg) { this._checkPgPass(() => { try { - this.saslSession = sasl.startSession(msg.mechanisms) + this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream) this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response) } catch (err) { this.connection.emit('error', err) @@ -268,7 +269,7 @@ class Client extends EventEmitter { async _handleAuthSASLContinue(msg) { try { - await sasl.continueSession(this.saslSession, this.password, msg.data) + await sasl.continueSession(this.saslSession, this.password, msg.data, this.enableChannelBinding && this.connection.stream) this.connection.sendSCRAMClientFinalMessage(this.saslSession.response) } catch (err) { this.connection.emit('error', err) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 04ae19724..d807c11cf 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,22 +1,40 @@ 'use strict' const crypto = require('./utils') +const tls = require('tls'); -function startSession(mechanisms) { - if (mechanisms.indexOf('SCRAM-SHA-256') === -1) { - throw new Error('SASL: Only mechanism SCRAM-SHA-256 is currently supported') +function startSession(mechanisms, stream) { + const candidates = ['SCRAM-SHA-256'] + if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first + + let mechanism + for (const candidate of candidates) { + if (mechanisms.indexOf(candidate) !== -1) { + mechanism = candidate + break + } + } + + if (!mechanism) { + throw new Error('SASL: Only mechanisms ' + candidates.join(' and ') + ' are supported') + } + + if (mechanism === 'SCRAM-SHA-256-PLUS' && !(stream instanceof tls.TLSSocket)) { + // this should never happen if we are really talking to a Postgres server + throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a secure connection') } const clientNonce = crypto.randomBytes(18).toString('base64') + const gs2Header = mechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : stream ? 'y' : 'n' return { - mechanism: 'SCRAM-SHA-256', + mechanism, clientNonce, - response: 'n,,n=*,r=' + clientNonce, + response: gs2Header + ',,n=*,r=' + clientNonce, message: 'SASLInitialResponse', } } -async function continueSession(session, password, serverData) { +async function continueSession(session, password, serverData, stream) { if (session.message !== 'SASLInitialResponse') { throw new Error('SASL: Last message was not SASLInitialResponse') } @@ -40,7 +58,34 @@ async function continueSession(session, password, serverData) { var clientFirstMessageBare = 'n=*,r=' + session.clientNonce var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration - var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce + + // without channel binding: + let channelBinding = stream ? 'eSws' : 'biws' // 'y,,' or 'n,,', base64-encoded + + // override if channel binding is in use: + if (session.mechanism === 'SCRAM-SHA-256-PLUS') { + const peerCert = stream.getPeerCertificate().raw + const x509 = await import('@peculiar/x509') + const parsedCert = new x509.X509Certificate(peerCert) + const sigAlgo = parsedCert.signatureAlgorithm + if (!sigAlgo) { + throw new Error('Could not extract signature algorithm from certificate') + } + const hash = sigAlgo.hash + if (!hash) { + throw new Error('Could not extract hash from certificate signature algorithm') + } + let hashName = hash.name + if (!hashName) { + throw new Error('Could not extract name from certificate signature algorithm hash') + } + if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256 + const certHash = await crypto.hashByName(hashName, peerCert) + const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) + channelBinding = bindingData.toString('base64') + } + + var clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof var saltBytes = Buffer.from(sv.salt, 'base64') diff --git a/packages/pg/lib/crypto/utils-legacy.js b/packages/pg/lib/crypto/utils-legacy.js index 86544ad00..2b2e2ab93 100644 --- a/packages/pg/lib/crypto/utils-legacy.js +++ b/packages/pg/lib/crypto/utils-legacy.js @@ -19,6 +19,10 @@ function sha256(text) { return nodeCrypto.createHash('sha256').update(text).digest() } +function hashByName(hashName, text) { + return nodeCrypto.createHash(hashName).update(text).digest() +} + function hmacSha256(key, msg) { return nodeCrypto.createHmac('sha256', key).update(msg).digest() } @@ -32,6 +36,7 @@ module.exports = { randomBytes: nodeCrypto.randomBytes, deriveKey, sha256, + hashByName, hmacSha256, md5, } diff --git a/packages/pg/lib/crypto/utils-webcrypto.js b/packages/pg/lib/crypto/utils-webcrypto.js index 0433f010c..819acba76 100644 --- a/packages/pg/lib/crypto/utils-webcrypto.js +++ b/packages/pg/lib/crypto/utils-webcrypto.js @@ -5,6 +5,7 @@ module.exports = { randomBytes, deriveKey, sha256, + hashByName, hmacSha256, md5, } @@ -60,6 +61,10 @@ async function sha256(text) { return await subtleCrypto.digest('SHA-256', text) } +async function hashByName(hashName, text) { + return await subtleCrypto.digest(hashName, text) +} + /** * Sign the message with the given key * @param {ArrayBuffer} keyBuffer diff --git a/packages/pg/package.json b/packages/pg/package.json index f33ba8e25..c494d9cb4 100644 --- a/packages/pg/package.json +++ b/packages/pg/package.json @@ -20,6 +20,7 @@ "author": "Brian Carlson ", "main": "./lib", "dependencies": { + "@peculiar/x509": "^1.12.3", "pg-connection-string": "^2.7.0", "pg-pool": "^3.7.0", "pg-protocol": "^1.7.0", diff --git a/packages/pg/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 242828716..845877ffe 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -45,12 +45,25 @@ if (!config.user || !config.password) { return } -suite.testAsync('can connect using sasl/scram', async () => { +suite.testAsync('can connect using sasl/scram (channel binding enabled)', async () => { const client = new pg.Client(config) let usingSasl = false client.connection.once('authenticationSASL', () => { usingSasl = true }) + client.enableChannelBinding = true // default + await client.connect() + assert.ok(usingSasl, 'Should be using SASL for authentication') + await client.end() +}) + +suite.testAsync('can connect using sasl/scram (channel binding disabled)', async () => { + const client = new pg.Client(config) + let usingSasl = false + client.connection.once('authenticationSASL', () => { + usingSasl = true + }) + client.enableChannelBinding = false await client.connect() assert.ok(usingSasl, 'Should be using SASL for authentication') await client.end() From a3385218fb08f4e07b9634c9d1d76ed2ed4dc363 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Tue, 14 Jan 2025 12:34:17 +0000 Subject: [PATCH 02/17] Requested tweaks to channel binding --- packages/pg/lib/client.js | 2 +- packages/pg/lib/crypto/sasl.js | 4 ++-- packages/pg/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 9a817b179..f885599a6 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -20,7 +20,7 @@ class Client extends EventEmitter { this.database = this.connectionParameters.database this.port = this.connectionParameters.port this.host = this.connectionParameters.host - this.enableChannelBinding = true + this.enableChannelBinding = false // set true to use SCRAM-SHA-256-PLUS when offered // "hiding" the password so it doesn't show up in stack traces // or if the client is console.logged diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index d807c11cf..096732d3f 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,6 +1,7 @@ 'use strict' const crypto = require('./utils') -const tls = require('tls'); +const tls = require('tls') +const x509 = require('@peculiar/x509') function startSession(mechanisms, stream) { const candidates = ['SCRAM-SHA-256'] @@ -65,7 +66,6 @@ async function continueSession(session, password, serverData, stream) { // override if channel binding is in use: if (session.mechanism === 'SCRAM-SHA-256-PLUS') { const peerCert = stream.getPeerCertificate().raw - const x509 = await import('@peculiar/x509') const parsedCert = new x509.X509Certificate(peerCert) const sigAlgo = parsedCert.signatureAlgorithm if (!sigAlgo) { diff --git a/packages/pg/package.json b/packages/pg/package.json index c494d9cb4..bd3be28c8 100644 --- a/packages/pg/package.json +++ b/packages/pg/package.json @@ -20,7 +20,7 @@ "author": "Brian Carlson ", "main": "./lib", "dependencies": { - "@peculiar/x509": "^1.12.3", + "@peculiar/x509": "1.12.3", "pg-connection-string": "^2.7.0", "pg-pool": "^3.7.0", "pg-protocol": "^1.7.0", From 8ab2b17e3bfdff37fbe3f90f72c72f2e9c43273b Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Tue, 14 Jan 2025 12:40:14 +0000 Subject: [PATCH 03/17] Additional tweaks to channel binding --- docs/pages/features/ssl.mdx | 9 +++++++++ packages/pg/test/integration/client/sasl-scram-tests.js | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/pages/features/ssl.mdx b/docs/pages/features/ssl.mdx index 2ead9ee47..2e36acec3 100644 --- a/docs/pages/features/ssl.mdx +++ b/docs/pages/features/ssl.mdx @@ -51,3 +51,12 @@ const config = { }, } ``` + +## Channel binding + +If the PostgreSQL server offers SCRAM-SHA-256-PLUS (i.e. channel binding) for TLS/SSL connections, you can enable this as follows: + +```js +const client = new Client(config) +client.enableChannelBinding = true +``` diff --git a/packages/pg/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 845877ffe..360694dcd 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -51,7 +51,7 @@ suite.testAsync('can connect using sasl/scram (channel binding enabled)', async client.connection.once('authenticationSASL', () => { usingSasl = true }) - client.enableChannelBinding = true // default + client.enableChannelBinding = true await client.connect() assert.ok(usingSasl, 'Should be using SASL for authentication') await client.end() @@ -63,7 +63,7 @@ suite.testAsync('can connect using sasl/scram (channel binding disabled)', async client.connection.once('authenticationSASL', () => { usingSasl = true }) - client.enableChannelBinding = false + client.enableChannelBinding = false // default await client.connect() assert.ok(usingSasl, 'Should be using SASL for authentication') await client.end() From 37f928517e061145a936357fb08e24fe6c13b723 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Tue, 14 Jan 2025 16:37:46 +0000 Subject: [PATCH 04/17] Fixed lint complaints --- packages/pg/lib/client.js | 9 +++++++-- packages/pg/lib/crypto/sasl.js | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index f885599a6..07071625c 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -20,7 +20,7 @@ class Client extends EventEmitter { this.database = this.connectionParameters.database this.port = this.connectionParameters.port this.host = this.connectionParameters.host - this.enableChannelBinding = false // set true to use SCRAM-SHA-256-PLUS when offered + this.enableChannelBinding = false // set true to use SCRAM-SHA-256-PLUS when offered // "hiding" the password so it doesn't show up in stack traces // or if the client is console.logged @@ -269,7 +269,12 @@ class Client extends EventEmitter { async _handleAuthSASLContinue(msg) { try { - await sasl.continueSession(this.saslSession, this.password, msg.data, this.enableChannelBinding && this.connection.stream) + await sasl.continueSession( + this.saslSession, + this.password, + msg.data, + this.enableChannelBinding && this.connection.stream + ) this.connection.sendSCRAMClientFinalMessage(this.saslSession.response) } catch (err) { this.connection.emit('error', err) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 096732d3f..7f322a3d8 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -5,7 +5,7 @@ const x509 = require('@peculiar/x509') function startSession(mechanisms, stream) { const candidates = ['SCRAM-SHA-256'] - if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first + if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first let mechanism for (const candidate of candidates) { @@ -79,7 +79,7 @@ async function continueSession(session, password, serverData, stream) { if (!hashName) { throw new Error('Could not extract name from certificate signature algorithm hash') } - if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256 + if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256 const certHash = await crypto.hashByName(hashName, peerCert) const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) channelBinding = bindingData.toString('base64') From ae6ab3f719d35f118297e9c651aae75abea8f0e3 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Thu, 16 Jan 2025 18:14:54 +0000 Subject: [PATCH 05/17] Update packages/pg/lib/crypto/sasl.js Co-authored-by: Charmander <~@charmander.me> --- packages/pg/lib/crypto/sasl.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 7f322a3d8..9641dda4c 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -7,13 +7,7 @@ function startSession(mechanisms, stream) { const candidates = ['SCRAM-SHA-256'] if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first - let mechanism - for (const candidate of candidates) { - if (mechanisms.indexOf(candidate) !== -1) { - mechanism = candidate - break - } - } + const mechanism = candidates.find(candidate => mechanisms.includes(candidate)) if (!mechanism) { throw new Error('SASL: Only mechanisms ' + candidates.join(' and ') + ' are supported') From 152396f315d2383e705ba8a9e442e8834f8916d6 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Thu, 16 Jan 2025 18:15:57 +0000 Subject: [PATCH 06/17] Update packages/pg/lib/crypto/sasl.js Co-authored-by: Charmander <~@charmander.me> --- packages/pg/lib/crypto/sasl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 9641dda4c..7e50b9cdd 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -73,7 +73,7 @@ async function continueSession(session, password, serverData, stream) { if (!hashName) { throw new Error('Could not extract name from certificate signature algorithm hash') } - if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256 + if (/^(md5|sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256 const certHash = await crypto.hashByName(hashName, peerCert) const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) channelBinding = bindingData.toString('base64') From f899f8f799ee5e9d76403ed7bdfe7ecb0eb0146b Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Thu, 16 Jan 2025 18:21:13 +0000 Subject: [PATCH 07/17] Update packages/pg/lib/client.js Co-authored-by: Charmander <~@charmander.me> --- packages/pg/lib/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 07071625c..9eb250484 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -20,7 +20,7 @@ class Client extends EventEmitter { this.database = this.connectionParameters.database this.port = this.connectionParameters.port this.host = this.connectionParameters.host - this.enableChannelBinding = false // set true to use SCRAM-SHA-256-PLUS when offered + this.enableChannelBinding = Boolean(config.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered // "hiding" the password so it doesn't show up in stack traces // or if the client is console.logged From 52e656cf96eaf18fb4a34ac47d89686f91d0fc63 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Thu, 16 Jan 2025 18:34:20 +0000 Subject: [PATCH 08/17] Tweaks to channel binding --- packages/pg/lib/crypto/sasl.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 7e50b9cdd..1c37811a3 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,21 +1,20 @@ 'use strict' const crypto = require('./utils') -const tls = require('tls') const x509 = require('@peculiar/x509') function startSession(mechanisms, stream) { const candidates = ['SCRAM-SHA-256'] if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first - const mechanism = candidates.find(candidate => mechanisms.includes(candidate)) + const mechanism = candidates.find((candidate) => mechanisms.includes(candidate)) if (!mechanism) { throw new Error('SASL: Only mechanisms ' + candidates.join(' and ') + ' are supported') } - if (mechanism === 'SCRAM-SHA-256-PLUS' && !(stream instanceof tls.TLSSocket)) { + if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') { // this should never happen if we are really talking to a Postgres server - throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a secure connection') + throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate') } const clientNonce = crypto.randomBytes(18).toString('base64') From 50ee3055f5448a75a9cf47cb3bf4c4ff94307492 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Mon, 20 Jan 2025 09:48:12 +0000 Subject: [PATCH 09/17] Now using homegrown certificate signature algorithm identification --- packages/pg/lib/crypto/cert-signatures.js | 121 ++++++++++++++++++ packages/pg/lib/crypto/sasl.js | 18 +-- packages/pg/package.json | 1 - .../integration/client/sasl-scram-tests.js | 22 ++-- 4 files changed, 134 insertions(+), 28 deletions(-) create mode 100644 packages/pg/lib/crypto/cert-signatures.js diff --git a/packages/pg/lib/crypto/cert-signatures.js b/packages/pg/lib/crypto/cert-signatures.js new file mode 100644 index 000000000..3497d963a --- /dev/null +++ b/packages/pg/lib/crypto/cert-signatures.js @@ -0,0 +1,121 @@ +function x509Error(msg, cert) { + throw new Error('SASL channel binding: ' + msg + ' when parsing public certificate ' + cert.toString('base64')) +} + +function readASN1Length(data, index) { + let length = data[index++] + if (length < 0x80) return { length, index } + + const lengthBytes = length & 0x7f + if (lengthBytes > 4) x509Error('bad length', data) + + length = 0 + for (let i = 0; i < lengthBytes; i++) { + length = (length << 8) | data[index++] + } + + return { length, index } +} + +function readASN1OID(data, index) { + if (data[index++] !== 0x6) x509Error('non-OID data', data) // 6 = OID + + const { length: OIDLength, index: indexAfterOIDLength } = readASN1Length(data, index) + index = indexAfterOIDLength + lastIndex = index + OIDLength + + const byte1 = data[index++] + let oid = ((byte1 / 40) >> 0) + '.' + (byte1 % 40) + + while (index < lastIndex) { + // loop over numbers in OID + let value = 0 + while (index < lastIndex) { + // loop over bytes in number + const nextByte = data[index++] + value = (value << 7) | (nextByte & 0x7f) + if (nextByte < 0x80) break + } + oid += '.' + value + } + + return { oid, index } +} + +function expectASN1Seq(data, index) { + if (data[index++] !== 0x30) x509Error('non-sequence data', data) // 30 = Sequence + return readASN1Length(data, index) +} + +function signatureAlgorithmHashFromCertificate(data, index) { + // read this thread: https://www.postgresql.org/message-id/17760-b6c61e752ec07060%40postgresql.org + if (index === undefined) index = 0 + index = expectASN1Seq(data, index).index + const { length: certInfoLength, index: indexAfterCertInfoLength } = expectASN1Seq(data, index) + index = indexAfterCertInfoLength + certInfoLength // skip over certificate info + index = expectASN1Seq(data, index).index // skip over signature length field + const { oid, index: indexAfterOID } = readASN1OID(data, index) + switch (oid) { + // RSA + case '1.2.840.113549.1.1.4': + return 'MD5' + case '1.2.840.113549.1.1.5': + return 'SHA-1' + case '1.2.840.113549.1.1.11': + return 'SHA-256' + case '1.2.840.113549.1.1.12': + return 'SHA-384' + case '1.2.840.113549.1.1.13': + return 'SHA-512' + case '1.2.840.113549.1.1.14': + return 'SHA-224' + case '1.2.840.113549.1.1.15': + return 'SHA512-224' + case '1.2.840.113549.1.1.16': + return 'SHA512-256' + // ECDSA + case '1.2.840.10045.4.1': + return 'SHA-1' + case '1.2.840.10045.4.3.1': + return 'SHA-224' + case '1.2.840.10045.4.3.2': + return 'SHA-256' + case '1.2.840.10045.4.3.3': + return 'SHA-384' + case '1.2.840.10045.4.3.4': + return 'SHA-512' + // RSASSA-PSS: hash is indicated separately + case '1.2.840.113549.1.1.10': + index = indexAfterOID + index = expectASN1Seq(data, index).index + if (data[index++] !== 0xa0) x509Error('non-tag data', data) // a0 = constructed tag 0 + index = readASN1Length(data, index).index // skip over tag length field + index = expectASN1Seq(data, index).index // skip over sequence length field + const { oid: hashOID } = readASN1OID(data, index) + switch (hashOID) { + // standalone hash OIDs + case '1.2.840.113549.2.5': + return 'MD5' + case '1.3.14.3.2.26': + return 'SHA-1' + case '2.16.840.1.101.3.4.2.1': + return 'SHA-256' + case '2.16.840.1.101.3.4.2.2': + return 'SHA-384' + case '2.16.840.1.101.3.4.2.3': + return 'SHA-512' + } + x509Error('unknown hash OID ' + hashOID, data) + // Ed25519 -- see https: return//github.com/openssl/openssl/issues/15477 + case '1.3.101.110': + case '1.3.101.112': // ph + return 'SHA-512' + // Ed448 -- still not in pg 17.2 (if supported, digest would be SHAKE256 x 64 bytes) + case '1.3.101.111': + case '1.3.101.113': // ph + x509Error('Ed448 certificate channel binding is not currently supported by Postgres') + } + x509Error('unknown OID ' + oid, data) +} + +module.exports = { signatureAlgorithmHashFromCertificate } diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 1c37811a3..136fa98a5 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,6 +1,6 @@ 'use strict' const crypto = require('./utils') -const x509 = require('@peculiar/x509') +const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') function startSession(mechanisms, stream) { const candidates = ['SCRAM-SHA-256'] @@ -59,20 +59,8 @@ async function continueSession(session, password, serverData, stream) { // override if channel binding is in use: if (session.mechanism === 'SCRAM-SHA-256-PLUS') { const peerCert = stream.getPeerCertificate().raw - const parsedCert = new x509.X509Certificate(peerCert) - const sigAlgo = parsedCert.signatureAlgorithm - if (!sigAlgo) { - throw new Error('Could not extract signature algorithm from certificate') - } - const hash = sigAlgo.hash - if (!hash) { - throw new Error('Could not extract hash from certificate signature algorithm') - } - let hashName = hash.name - if (!hashName) { - throw new Error('Could not extract name from certificate signature algorithm hash') - } - if (/^(md5|sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256 + let hashName = signatureAlgorithmHashFromCertificate(peerCert) + if (hashName === 'MD5' || hashName === 'SHA-1') hashName = 'SHA-256' const certHash = await crypto.hashByName(hashName, peerCert) const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) channelBinding = bindingData.toString('base64') diff --git a/packages/pg/package.json b/packages/pg/package.json index bd3be28c8..f33ba8e25 100644 --- a/packages/pg/package.json +++ b/packages/pg/package.json @@ -20,7 +20,6 @@ "author": "Brian Carlson ", "main": "./lib", "dependencies": { - "@peculiar/x509": "1.12.3", "pg-connection-string": "^2.7.0", "pg-pool": "^3.7.0", "pg-protocol": "^1.7.0", diff --git a/packages/pg/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 360694dcd..7b8d5eafc 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -46,26 +46,24 @@ if (!config.user || !config.password) { } suite.testAsync('can connect using sasl/scram (channel binding enabled)', async () => { - const client = new pg.Client(config) - let usingSasl = false - client.connection.once('authenticationSASL', () => { - usingSasl = true + const client = new pg.Client({ ...config, enableChannelBinding: true }) + let usingChannelBinding = false + client.connection.once('authenticationSASLContinue', () => { + usingChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256-PLUS' }) - client.enableChannelBinding = true await client.connect() - assert.ok(usingSasl, 'Should be using SASL for authentication') + assert.ok(usingChannelBinding, 'Should be using SCRAM-SHA-256-PLUS for authentication') await client.end() }) suite.testAsync('can connect using sasl/scram (channel binding disabled)', async () => { - const client = new pg.Client(config) - let usingSasl = false - client.connection.once('authenticationSASL', () => { - usingSasl = true + const client = new pg.Client({ ...config, enableChannelBinding: false }) + let usingSASLWithoutChannelBinding = false + client.connection.once('authenticationSASLContinue', () => { + usingSASLWithoutChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256' }) - client.enableChannelBinding = false // default await client.connect() - assert.ok(usingSasl, 'Should be using SASL for authentication') + assert.ok(usingSASLWithoutChannelBinding, 'Should be using SCRAM-SHA-256 (no channel binding) for authentication') await client.end() }) From b3a8757f1851d6bc90090d9cfc7914b67bc27d54 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Mon, 20 Jan 2025 09:52:29 +0000 Subject: [PATCH 10/17] Update ssl.mdx with channel binding changes --- docs/pages/features/ssl.mdx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/pages/features/ssl.mdx b/docs/pages/features/ssl.mdx index 2e36acec3..a7609ea00 100644 --- a/docs/pages/features/ssl.mdx +++ b/docs/pages/features/ssl.mdx @@ -57,6 +57,11 @@ const config = { If the PostgreSQL server offers SCRAM-SHA-256-PLUS (i.e. channel binding) for TLS/SSL connections, you can enable this as follows: ```js -const client = new Client(config) -client.enableChannelBinding = true +const client = new Client({ ...config, enableChannelBinding: true}) +``` + +or + +```js +const pool = new Pool({ ...config, enableChannelBinding: true}) ``` From 67a6e9c6d1ed2972ff544ca8b6bbba3b41d729a7 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Mon, 20 Jan 2025 10:44:01 +0000 Subject: [PATCH 11/17] Allow for config object being undefined when assigning enableChannelBinding --- packages/pg/lib/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 9eb250484..a2405e67b 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -20,7 +20,6 @@ class Client extends EventEmitter { this.database = this.connectionParameters.database this.port = this.connectionParameters.port this.host = this.connectionParameters.host - this.enableChannelBinding = Boolean(config.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered // "hiding" the password so it doesn't show up in stack traces // or if the client is console.logged @@ -44,6 +43,7 @@ class Client extends EventEmitter { this._connectionError = false this._queryable = true + this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered this.connection = c.connection || new Connection({ From b6040687f14c6ea5c50ff4b4a0f80109ec4c7930 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Mon, 20 Jan 2025 10:46:46 +0000 Subject: [PATCH 12/17] Fixed a test failing on an updated error message --- packages/pg/lib/crypto/sasl.js | 2 +- packages/pg/test/unit/client/sasl-scram-tests.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 136fa98a5..690350fe3 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -9,7 +9,7 @@ function startSession(mechanisms, stream) { const mechanism = candidates.find((candidate) => mechanisms.includes(candidate)) if (!mechanism) { - throw new Error('SASL: Only mechanisms ' + candidates.join(' and ') + ' are supported') + throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported') } if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') { diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 2e1ed6a2f..04862f29d 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -14,7 +14,7 @@ suite.test('sasl/scram', function () { sasl.startSession([]) }, { - message: 'SASL: Only mechanism SCRAM-SHA-256 is currently supported', + message: 'SASL: Only mechanism(s) SCRAM-SHA-256 are supported', } ) }) From 88cbe4995a1b6db07d632acad4b58ebdf85d0961 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Tue, 21 Jan 2025 09:50:11 +0000 Subject: [PATCH 13/17] Removed - from hash names like SHA-256 for legacy crypto (Node 14 and below) --- package.json | 3 ++- packages/pg/lib/crypto/utils-legacy.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ce4ede68c..f73663a05 100644 --- a/package.json +++ b/package.json @@ -36,5 +36,6 @@ "arrowParens": "always", "trailingComma": "es5", "singleQuote": true - } + }, + "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" } diff --git a/packages/pg/lib/crypto/utils-legacy.js b/packages/pg/lib/crypto/utils-legacy.js index 2b2e2ab93..285c689f4 100644 --- a/packages/pg/lib/crypto/utils-legacy.js +++ b/packages/pg/lib/crypto/utils-legacy.js @@ -20,6 +20,7 @@ function sha256(text) { } function hashByName(hashName, text) { + hashName = hashName.replace(/(\D)-/, '$1') // e.g. SHA-256 -> SHA256 return nodeCrypto.createHash(hashName).update(text).digest() } From 05690bb714c48fb3c04da6fb9df4736b722f5b9f Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Wed, 22 Jan 2025 13:54:55 +0000 Subject: [PATCH 14/17] Removed packageManager key from package.json --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index f73663a05..ce4ede68c 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,5 @@ "arrowParens": "always", "trailingComma": "es5", "singleQuote": true - }, - "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" + } } From d9fdccf424769a81182e1e9ca4cc648c5e97e69f Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Tue, 28 Jan 2025 13:47:00 +0000 Subject: [PATCH 15/17] Added some SASL/channel binding unit tests --- .../pg/test/unit/client/sasl-scram-tests.js | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 04862f29d..eda3a25c8 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -19,14 +19,34 @@ suite.test('sasl/scram', function () { ) }) - suite.test('returns expected session data', function () { - const session = sasl.startSession(['SCRAM-SHA-256']) + suite.test('returns expected session data for SCRAM-SHA-256 (channel binding disabled, offered)', function () { + const session = sasl.startSession(['SCRAM-SHA-256', 'SCRAM-SHA-256-PLUS']) 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}/)) + assert(session.response.match(/^n,,n=\*,r=.{24}$/)) + }) + + suite.test('returns expected session data for SCRAM-SHA-256 (channel binding enabled, not offered)', function () { + const session = sasl.startSession(['SCRAM-SHA-256'], { getPeerCertificate() {} }) + + assert.equal(session.mechanism, 'SCRAM-SHA-256') + assert.equal(String(session.clientNonce).length, 24) + assert.equal(session.message, 'SASLInitialResponse') + + assert(session.response.match(/^y,,n=\*,r=.{24}$/)) + }) + + suite.test('returns expected session data for SCRAM-SHA-256 (channel binding enabled, offered)', function () { + const session = sasl.startSession(['SCRAM-SHA-256', 'SCRAM-SHA-256-PLUS'], { getPeerCertificate() {} }) + + assert.equal(session.mechanism, 'SCRAM-SHA-256-PLUS') + assert.equal(String(session.clientNonce).length, 24) + assert.equal(session.message, 'SASLInitialResponse') + + assert(session.response.match(/^p=tls-server-end-point,,n=\*,r=.{24}$/)) }) suite.test('creates random nonces', function () { From 9a91cb75d96a034f0a3be9a3d96501c3b3bc1859 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Tue, 28 Jan 2025 14:25:59 +0000 Subject: [PATCH 16/17] Added a unit test for continueSession to check expected SASL session data --- .../pg/test/unit/client/sasl-scram-tests.js | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index eda3a25c8..ba9b9304b 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -176,7 +176,7 @@ suite.test('sasl/scram', function () { ) }) - suite.testAsync('sets expected session data', async function () { + suite.testAsync('sets expected session data (SCRAM-SHA-256)', async function () { const session = { message: 'SASLInitialResponse', clientNonce: 'a', @@ -189,6 +189,70 @@ suite.test('sasl/scram', function () { assert.equal(session.response, 'c=biws,r=ab,p=mU8grLfTjDrJer9ITsdHk0igMRDejG10EJPFbIBL3D0=') }) + + suite.testAsync('sets expected session data (SCRAM-SHA-256, channel binding enabled)', async function () { + const session = { + message: 'SASLInitialResponse', + clientNonce: 'a', + } + + await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1', { getPeerCertificate() {} }) + + assert.equal(session.message, 'SASLResponse') + assert.equal(session.serverSignature, 'ETpURSc5OpddrPRSW3LaDPJzUzhh+rciM4uYwXSsohU=') + + assert.equal(session.response, 'c=eSws,r=ab,p=YVTEOwOD7khu/NulscjFegHrZoTXJBFI/7L61AN9khc=') + }) + + suite.testAsync('sets expected session data (SCRAM-SHA-256-PLUS)', async function () { + const session = { + message: 'SASLInitialResponse', + mechanism: 'SCRAM-SHA-256-PLUS', + clientNonce: 'a', + } + + await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1', { + getPeerCertificate() { + return { + raw: Buffer.from([ + // a minimal ASN.1 certificate structure which can be parsed for a hash type + 0x30, // cert ASN.1 seq + 0x16, // cert length (all bytes below) + 0x30, // cert info ASN.1 seq + 0x01, // cert info length + 0x00, // cert info (skipped) + 0x30, // signature algorithm ASN.1 seq + 0x0d, // signature algorithm length + 0x06, // ASN.1 OID + 0x09, // OID length + 0x2a, // OID: 1.2.840.113549.1.1.11 (RSASSA-PKCS1-v1_5 / SHA-256​) + 0x86, + 0x48, + 0x86, + 0xf7, + 0x0d, + 0x01, + 0x01, + 0x0b, + 0x05, // ASN.1 null (no algorithm parameters) + 0x00, // null length + 0x03, // ASN.1 bitstring (signature) + 0x02, // bitstring length + 0x00, // zero right-padding bits + 0xff, // one-byte signature + ]), + } + }, + }) + + assert.equal(session.message, 'SASLResponse') + assert.equal(session.serverSignature, 'pU1hc6JkjvjO8Wd+o0/jyGjc1DpITtsx1UF+ZPa5u5M=') + + assert.equal( + session.response, + 'c=cD10bHMtc2VydmVyLWVuZC1wb2ludCwsmwepqKDDRcOvo3BN0rplYMfLUTpbaf38btkM5aAXBhQ=,r=ab,p=j0v2LsthoNaIBrKV4YipskF/lV8zWEt6acNRtt99MA4=' + ) + }) }) suite.test('finalizeSession', function () { From 8fc5f5f6c86f04f92b523b889c1328fe8fe19870 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Thu, 13 Feb 2025 15:58:37 +0000 Subject: [PATCH 17/17] Modify tests: don't require channel binding (which cannot then work) if not using SSL --- packages/pg/test/integration/client/sasl-scram-tests.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/pg/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 7b8d5eafc..ce5d63e65 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -45,18 +45,20 @@ if (!config.user || !config.password) { return } -suite.testAsync('can connect using sasl/scram (channel binding enabled)', async () => { +suite.testAsync('can connect using sasl/scram with channel binding enabled (if using SSL)', async () => { const client = new pg.Client({ ...config, enableChannelBinding: true }) let usingChannelBinding = false + let hasPeerCert = false client.connection.once('authenticationSASLContinue', () => { + hasPeerCert = client.connection.stream.getPeerCertificate === 'function' usingChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256-PLUS' }) await client.connect() - assert.ok(usingChannelBinding, 'Should be using SCRAM-SHA-256-PLUS for authentication') + assert.ok(usingChannelBinding || !hasPeerCert, 'Should be using SCRAM-SHA-256-PLUS for authentication if using SSL') await client.end() }) -suite.testAsync('can connect using sasl/scram (channel binding disabled)', async () => { +suite.testAsync('can connect using sasl/scram with channel binding disabled', async () => { const client = new pg.Client({ ...config, enableChannelBinding: false }) let usingSASLWithoutChannelBinding = false client.connection.once('authenticationSASLContinue', () => {