diff --git a/docs/pages/features/ssl.mdx b/docs/pages/features/ssl.mdx index 2ead9ee47..a7609ea00 100644 --- a/docs/pages/features/ssl.mdx +++ b/docs/pages/features/ssl.mdx @@ -51,3 +51,17 @@ 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, enableChannelBinding: true}) +``` + +or + +```js +const pool = new Pool({ ...config, enableChannelBinding: true}) +``` diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 527f62e4f..a2405e67b 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -43,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({ @@ -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,12 @@ 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/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 04ae19724..690350fe3 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -1,22 +1,34 @@ 'use strict' const crypto = require('./utils') +const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures') -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 + + const mechanism = candidates.find((candidate) => mechanisms.includes(candidate)) + + if (!mechanism) { + throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported') + } + + 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 certificate') } 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 +52,21 @@ 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 + 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') + } + + 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..285c689f4 100644 --- a/packages/pg/lib/crypto/utils-legacy.js +++ b/packages/pg/lib/crypto/utils-legacy.js @@ -19,6 +19,11 @@ function sha256(text) { return nodeCrypto.createHash('sha256').update(text).digest() } +function hashByName(hashName, text) { + hashName = hashName.replace(/(\D)-/, '$1') // e.g. SHA-256 -> SHA256 + return nodeCrypto.createHash(hashName).update(text).digest() +} + function hmacSha256(key, msg) { return nodeCrypto.createHmac('sha256', key).update(msg).digest() } @@ -32,6 +37,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/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 242828716..ce5d63e65 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -45,14 +45,27 @@ if (!config.user || !config.password) { return } -suite.testAsync('can connect using sasl/scram', async () => { - const client = new pg.Client(config) - let usingSasl = false - client.connection.once('authenticationSASL', () => { - usingSasl = true +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(usingSasl, 'Should be using SASL 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 with channel binding disabled', async () => { + const client = new pg.Client({ ...config, enableChannelBinding: false }) + let usingSASLWithoutChannelBinding = false + client.connection.once('authenticationSASLContinue', () => { + usingSASLWithoutChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256' + }) + await client.connect() + assert.ok(usingSASLWithoutChannelBinding, 'Should be using SCRAM-SHA-256 (no channel binding) for authentication') await client.end() }) diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 2e1ed6a2f..ba9b9304b 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -14,19 +14,39 @@ 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', } ) }) - 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 () { @@ -156,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', @@ -169,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 () {