Add support for SCRAM-SHA-256-PLUS i.e. channel binding (#3356)

* Added support for SCRAM-SHA-256-PLUS i.e. channel binding

* Requested tweaks to channel binding

* Additional tweaks to channel binding

* Fixed lint complaints

* Update packages/pg/lib/crypto/sasl.js

Co-authored-by: Charmander <~@charmander.me>

* Update packages/pg/lib/crypto/sasl.js

Co-authored-by: Charmander <~@charmander.me>

* Update packages/pg/lib/client.js

Co-authored-by: Charmander <~@charmander.me>

* Tweaks to channel binding

* Now using homegrown certificate signature algorithm identification

* Update ssl.mdx with channel binding changes

* Allow for config object being undefined when assigning enableChannelBinding

* Fixed a test failing on an updated error message

* Removed - from hash names like SHA-256 for legacy crypto (Node 14 and below)

* Removed packageManager key from package.json

* Added some SASL/channel binding unit tests

* Added a unit test for continueSession to check expected SASL session data

* Modify tests: don't require channel binding (which cannot then work) if not using SSL

---------

Co-authored-by: Charmander <~@charmander.me>
This commit is contained in:
George MacKerron 2025-03-10 18:13:32 +00:00 committed by GitHub
parent 1876f2000a
commit b4022aa5c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 295 additions and 20 deletions

View File

@ -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})
```

View File

@ -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({
@ -262,7 +263,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)
@ -272,7 +273,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)

View File

@ -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 }

View File

@ -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')

View File

@ -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,
}

View File

@ -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

View File

@ -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()
})

View File

@ -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 () {